├── tienda-api-nest-js ├── .prettierrc ├── tsconfig.build.json ├── nest-cli.json ├── test │ ├── jest-e2e.json │ ├── app.e2e-spec.ts │ └── rest │ │ ├── categorias │ │ └── categorias.e2e-spec.ts │ │ └── productos │ │ └── productos.e2e-spec.ts ├── src │ ├── rest │ │ ├── users │ │ │ ├── dto │ │ │ │ ├── user-response.dto.ts │ │ │ │ ├── update-user.dto.ts │ │ │ │ └── create-user.dto.ts │ │ │ ├── bcrypt.service.ts │ │ │ ├── entities │ │ │ │ ├── user-role.entity.ts │ │ │ │ └── user.entity.ts │ │ │ ├── guards │ │ │ │ └── roles-exists.guard.ts │ │ │ ├── users.module.ts │ │ │ ├── mappers │ │ │ │ └── usuarios.mapper.ts │ │ │ ├── users.controller.ts │ │ │ └── users.service.ts │ │ ├── categorias │ │ │ ├── dto │ │ │ │ ├── create-categoria.dto.ts │ │ │ │ └── update-categoria.dto.ts │ │ │ ├── mappers │ │ │ │ ├── categorias.mapper.ts │ │ │ │ └── categorias.mapper.spec.ts │ │ │ ├── categorias.module.ts │ │ │ ├── entities │ │ │ │ └── categoria.entity.ts │ │ │ ├── categorias.controller.ts │ │ │ ├── categorias.controller.spec.ts │ │ │ ├── categorias.service.ts │ │ │ └── categorias.service.spec.ts │ │ ├── storage │ │ │ ├── storage.module.ts │ │ │ ├── storage.service.spec.ts │ │ │ ├── storage.controller.spec.ts │ │ │ ├── storage.service.ts │ │ │ └── storage.controller.ts │ │ ├── pedidos │ │ │ ├── mappers │ │ │ │ └── pedidos.mapper.ts │ │ │ ├── pipes │ │ │ │ ├── id-validate.pipe.ts │ │ │ │ ├── order-validate.pipe.ts │ │ │ │ └── orderby-validate.pipe.ts │ │ │ ├── dto │ │ │ │ ├── update-pedido.dto.ts │ │ │ │ └── create-pedido.dto.ts │ │ │ ├── guards │ │ │ │ └── usuario-exists.guard.ts │ │ │ ├── pedidos.module.ts │ │ │ ├── pedidos.controller.ts │ │ │ ├── schemas │ │ │ │ └── pedido.schema.ts │ │ │ └── pedidos.service.ts │ │ ├── auth │ │ │ ├── dto │ │ │ │ ├── user-sign.in.dto.ts │ │ │ │ └── user-sign.up.dto.ts │ │ │ ├── guards │ │ │ │ ├── jwt-auth.guard.ts │ │ │ │ └── roles-auth.guard.ts │ │ │ ├── mappers │ │ │ │ └── usuarios.mapper.ts │ │ │ ├── strategies │ │ │ │ └── jwt-strategy.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.controller.ts │ │ │ └── auth.service.ts │ │ └── productos │ │ │ ├── guards │ │ │ └── producto-exists.guard.ts │ │ │ ├── productos.module.ts │ │ │ ├── dto │ │ │ ├── response-producto.dto.ts │ │ │ ├── update-producto.dto.ts │ │ │ └── create-producto.dto.ts │ │ │ ├── mappers │ │ │ ├── productos.mapper.ts │ │ │ └── productos.mapper.spec.ts │ │ │ ├── entities │ │ │ └── producto.entity.ts │ │ │ ├── productos.controller.spec.ts │ │ │ ├── productos.controller.ts │ │ │ ├── productos.service.ts │ │ │ └── productos.service.spec.ts │ ├── config │ │ ├── ssl │ │ │ └── ssl.config.ts │ │ ├── cors │ │ │ └── cors.module.ts │ │ ├── swagger │ │ │ └── swagger.config.ts │ │ └── database │ │ │ └── database.module.ts │ ├── websockets │ │ └── notifications │ │ │ ├── notifications.module.ts │ │ │ ├── models │ │ │ └── notificacion.model.ts │ │ │ ├── dto │ │ │ └── producto-notificacion.dto.ts │ │ │ └── products-notifications.gateway.ts │ ├── app.module.ts │ └── main.ts ├── .env ├── .env.prod ├── .gitignore ├── cert │ ├── README.md │ ├── cert.pem │ └── keystore.p12 ├── tsconfig.json ├── .eslintrc.js ├── Dockerfile ├── database │ ├── tienda.js │ └── tienda.sql ├── docker-compose.yaml ├── docker-compose-db.yaml ├── package.json └── README.md ├── image-demo.png └── images └── banner.jpg /tienda-api-nest-js/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /image-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/DesarrolloWebEntornosServidor-03-Proyecto-2023-2024/HEAD/image-demo.png -------------------------------------------------------------------------------- /images/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseluisgs/DesarrolloWebEntornosServidor-03-Proyecto-2023-2024/HEAD/images/banner.jpg -------------------------------------------------------------------------------- /tienda-api-nest-js/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tienda-api-nest-js/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tienda-api-nest-js/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 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/dto/user-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserDto { 2 | id: number 3 | nombre: string 4 | apellidos: string 5 | email: string 6 | username: string 7 | createdAt: Date 8 | updatedAt: Date 9 | isDeleted: boolean 10 | roles: string[] 11 | } 12 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/dto/create-categoria.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length } from 'class-validator' 2 | 3 | export class CreateCategoriaDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @Length(3, 100, { message: 'El nombre debe tener entre 3 y 100 caracteres' }) 7 | nombre: string 8 | } 9 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/config/ssl/ssl.config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { resolve } from 'path' 3 | 4 | export function getSSLOptions() { 5 | const key = readFileSync(resolve(process.env.SSL_KEY)) 6 | const cert = readFileSync(resolve(process.env.SSL_CERT)) 7 | 8 | return { key, cert } 9 | } 10 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/websockets/notifications/notifications.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ProductsNotificationsGateway } from './products-notifications.gateway' 3 | 4 | @Module({ 5 | providers: [ProductsNotificationsGateway], 6 | exports: [ProductsNotificationsGateway], 7 | }) 8 | export class NotificationsModule {} 9 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/websockets/notifications/models/notificacion.model.ts: -------------------------------------------------------------------------------- 1 | export class Notificacion { 2 | constructor( 3 | public entity: string, 4 | public type: NotificacionTipo, 5 | public data: T, 6 | public createdAt: Date, 7 | ) {} 8 | } 9 | 10 | export enum NotificacionTipo { 11 | CREATE = 'CREATE', 12 | UPDATE = 'UPDATE', 13 | DELETE = 'DELETE', 14 | } 15 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/storage/storage.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { StorageService } from './storage.service' 3 | import { StorageController } from './storage.controller' 4 | 5 | @Module({ 6 | controllers: [StorageController], 7 | providers: [StorageService], 8 | exports: [StorageService], // exportamos el servicio para que pueda ser usado por otros módulos 9 | }) 10 | export class StorageModule {} 11 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/mappers/pedidos.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { CreatePedidoDto } from '../dto/create-pedido.dto' 3 | import { Pedido } from '../schemas/pedido.schema' 4 | import { plainToClass } from 'class-transformer' 5 | 6 | @Injectable() 7 | export class PedidosMapper { 8 | toEntity(createPedidoDto: CreatePedidoDto): Pedido { 9 | return plainToClass(Pedido, createPedidoDto) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tienda-api-nest-js/.env: -------------------------------------------------------------------------------- 1 | API_PORT=3000 2 | API_VERSION=v1 3 | DATABASE_USER=admin 4 | DATABASE_PASSWORD=adminPassword123 5 | POSTGRES_HOST=localhost 6 | POSTGRES_PORT=5432 7 | POSTGRES_DATABASE=tienda 8 | MONGO_HOST=localhost 9 | MONGO_PORT=27017 10 | MONGO_DATABASE=tienda 11 | NODE_ENV=dev 12 | UPLOADS_DIR=storage-dir 13 | TOKEN_SECRET=Me_Gustan_Los_Pepinos_De_Leganes_Porque_Son_Grandes_Y_Hermosos 14 | TOKEN_EXPIRES=3600 15 | SSL_KEY=./cert/keystore.p12 16 | SSL_CERT=./cert/cert.pem 17 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/bcrypt.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import * as bcrypt from 'bcryptjs' 3 | 4 | @Injectable() 5 | export class BcryptService { 6 | private ROUNDS = 12 7 | 8 | async hash(password: string): Promise { 9 | return await bcrypt.hash(password, this.ROUNDS) 10 | } 11 | 12 | async isMatch(password: string, hash: string): Promise { 13 | return await bcrypt.compare(password, hash) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tienda-api-nest-js/.env.prod: -------------------------------------------------------------------------------- 1 | API_PORT=3000 2 | API_VERSION=v1 3 | DATABASE_USER=admin 4 | DATABASE_PASSWORD=adminPassword123 5 | POSTGRES_HOST=postgres-db 6 | POSTGRES_PORT=5432 7 | POSTGRES_DATABASE=tienda 8 | MONGO_HOST=mongo-db 9 | MONGO_PORT=27017 10 | MONGO_DATABASE=tienda 11 | NODE_ENV=dev 12 | UPLOADS_DIR=storage-dir 13 | TOKEN_SECRET=Me_Gustan_Los_Pepinos_De_Leganes_Porque_Son_Grandes_Y_Hermosos 14 | TOKEN_EXPIRES=3600 15 | SSL_KEY=./cert/keystore.p12 16 | SSL_CERT=./cert/cert.pem 17 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pipes/id-validate.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common' 2 | import { ObjectId } from 'mongodb' 3 | 4 | @Injectable() 5 | export class IdValidatePipe implements PipeTransform { 6 | transform(value: any) { 7 | if (!ObjectId.isValid(value)) { 8 | throw new BadRequestException( 9 | 'El id especificado no es válido o no tiene el formato correcto', 10 | ) 11 | } 12 | return value 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/dto/update-pedido.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types' 2 | import { 3 | ClienteDto, 4 | CreatePedidoDto, 5 | LineaPedidoDto, 6 | } from './create-pedido.dto' 7 | import { IsNotEmpty, IsNumber } from 'class-validator' 8 | 9 | export class UpdatePedidoDto extends PartialType(CreatePedidoDto) { 10 | @IsNumber() 11 | @IsNotEmpty() 12 | idUsuario: number 13 | 14 | @IsNotEmpty() 15 | cliente: ClienteDto 16 | 17 | @IsNotEmpty() 18 | lineasPedido: LineaPedidoDto[] 19 | } 20 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/websockets/notifications/dto/producto-notificacion.dto.ts: -------------------------------------------------------------------------------- 1 | export class ProductoNotificacionResponse { 2 | constructor( 3 | public id: number, 4 | public marca: string, 5 | public modelo: string, 6 | public descripcion: string, 7 | public precio: number, 8 | public imagen: string, 9 | public stock: number, 10 | public categoria: string, 11 | public uuid: string, 12 | public isDeleted: boolean, 13 | public createdAt: string, 14 | public updatedAt: string, 15 | ) {} 16 | } 17 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/dto/update-categoria.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types' 2 | import { CreateCategoriaDto } from './create-categoria.dto' 3 | import { 4 | IsBoolean, 5 | IsNotEmpty, 6 | IsOptional, 7 | IsString, 8 | Length, 9 | } from 'class-validator' 10 | 11 | export class UpdateCategoriaDto extends PartialType(CreateCategoriaDto) { 12 | @IsOptional() 13 | @IsString() 14 | @IsNotEmpty() 15 | @Length(1, 100) 16 | nombre?: string 17 | 18 | @IsBoolean() 19 | @IsOptional() 20 | isDeleted?: boolean 21 | } 22 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/dto/user-sign.in.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator' 2 | import { ApiProperty } from '@nestjs/swagger' 3 | 4 | export class UserSignInDto { 5 | @ApiProperty({ example: 'john_doe', description: 'Nombre de usuario' }) 6 | @IsNotEmpty({ message: 'Username no puede estar vacío' }) 7 | username: string 8 | 9 | @ApiProperty({ example: 'password123', description: 'Contraseña' }) 10 | @IsString({ message: 'Password no es válido' }) 11 | @IsNotEmpty({ message: 'Password no puede estar vacío' }) 12 | password: string 13 | } 14 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/storage/storage.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { StorageService } from './storage.service' 3 | 4 | describe('StorageService', () => { 5 | let service: StorageService 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [StorageService], 10 | }).compile() 11 | 12 | service = module.get(StorageService) 13 | }) 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types' 2 | import { CreateUserDto } from './create-user.dto' 3 | import { IsOptional } from 'class-validator' 4 | 5 | export class UpdateUserDto extends PartialType(CreateUserDto) { 6 | @IsOptional() 7 | nombre: string 8 | @IsOptional() 9 | apellidos: string 10 | @IsOptional() 11 | username: string 12 | @IsOptional() 13 | email: string 14 | @IsOptional() 15 | roles: string[] 16 | @IsOptional() 17 | password: string 18 | @IsOptional() 19 | isDeleted: boolean 20 | } 21 | -------------------------------------------------------------------------------- /tienda-api-nest-js/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | /storage-dir/ 37 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/mappers/categorias.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { CreateCategoriaDto } from '../dto/create-categoria.dto' 3 | import { CategoriaEntity } from '../entities/categoria.entity' 4 | import { plainToClass } from 'class-transformer' 5 | import { UpdateCategoriaDto } from '../dto/update-categoria.dto' 6 | 7 | @Injectable() 8 | export class CategoriasMapper { 9 | toEntity( 10 | createCategoriaDto: CreateCategoriaDto | UpdateCategoriaDto, 11 | ): CategoriaEntity { 12 | return plainToClass(CategoriaEntity, createCategoriaDto) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pipes/order-validate.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common' 2 | import { PedidosOrderValues } from '../pedidos.service' 3 | 4 | @Injectable() 5 | export class OrderValidatePipe implements PipeTransform { 6 | transform(value: any) { 7 | value = value || PedidosOrderValues[0] 8 | if (!PedidosOrderValues.includes(value)) { 9 | throw new BadRequestException( 10 | `No se ha especificado un orden válido: ${PedidosOrderValues.join( 11 | ', ', 12 | )}`, 13 | ) 14 | } 15 | return value 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { Observable } from 'rxjs' 4 | 5 | /* 6 | "Cuando este guardián se use en una ruta, valida el token JWT en la solicitud y, 7 | si es válido, permite el acceso. Si no es válido, niega el acceso." 8 | */ 9 | 10 | @Injectable() 11 | export class JwtAuthGuard extends AuthGuard('jwt') { 12 | canActivate( 13 | context: ExecutionContext, 14 | ): boolean | Promise | Observable { 15 | return super.canActivate(context) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tienda-api-nest-js/cert/README.md: -------------------------------------------------------------------------------- 1 | # Certificados con OpenSSL 2 | 3 | ## Crear un certificado autofirmado 4 | 5 | ```bash 6 | openssl genrsa -out keystore.p12 2048 7 | openssl req -new -x509 -key keystore.p12 -out cert.pem -days 365 8 | ``` 9 | 10 | ## Instalar OpenSSL en Windows 11 | 12 | Ir a: https://slproweb.com/products/Win32OpenSSL.html 13 | 14 | Seleccionar la versión de acuerdo a la arquitectura del sistema operativo. 15 | 16 | Se recomienda la version light. 17 | 18 | Luego usar la consola de OpenSSL que se instala en el menú de inicio. 19 | 20 | ## Instalar OpenSSL en Linux 21 | 22 | ```bash 23 | sudo apt-get install openssl 24 | ``` 25 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/config/cors/cors.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common' 2 | 3 | @Module({}) 4 | export class CorsConfigModule implements NestModule { 5 | configure(consumer: MiddlewareConsumer) { 6 | consumer 7 | .apply((req, res, next) => { 8 | res.header('Access-Control-Allow-Origin', '*') 9 | res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') 10 | res.header( 11 | 'Access-Control-Allow-Headers', 12 | 'Origin, X-Requested-With, Content-Type, Accept', 13 | ) 14 | next() 15 | }) 16 | .forRoutes('*') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tienda-api-nest-js/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": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/storage/storage.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { StorageController } from './storage.controller' 3 | import { StorageService } from './storage.service' 4 | 5 | describe('StorageController', () => { 6 | let controller: StorageController 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [StorageController], 11 | providers: [StorageService], 12 | }).compile() 13 | 14 | controller = module.get(StorageController) 15 | }) 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/entities/user-role.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm' 8 | import { Usuario } from './user.entity' 9 | 10 | export enum Role { 11 | USER = 'USER', 12 | ADMIN = 'ADMIN', 13 | } 14 | 15 | @Entity({ name: 'user_roles' }) 16 | export class UserRole { 17 | @PrimaryGeneratedColumn() 18 | id: number 19 | 20 | // Lo almaceno como varchar porque es más fácil de leer 21 | @Column({ type: 'varchar', length: 50, nullable: false, default: Role.USER }) 22 | role: Role 23 | 24 | @ManyToOne(() => Usuario, (user) => user.roles) 25 | @JoinColumn({ name: 'user_id' }) 26 | usuario: Usuario 27 | } 28 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pipes/orderby-validate.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common' 2 | import { PedidosOrderByValues } from '../pedidos.service' 3 | 4 | @Injectable() 5 | export class OrderByValidatePipe implements PipeTransform { 6 | transform(value: any) { 7 | // Lógica para verificar si el orderBy es válido: meter aquí la lógica 8 | value = value || PedidosOrderByValues[0] 9 | if (!PedidosOrderByValues.includes(value)) { 10 | throw new BadRequestException( 11 | `No se ha especificado un campo para ordenar válido: ${PedidosOrderByValues.join( 12 | ', ', 13 | )}`, 14 | ) 15 | } 16 | return value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tienda-api-nest-js/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { INestApplication } from '@nestjs/common' 3 | import * as request from 'supertest' 4 | import { AppModule } from '../../../nestjs-laboratorios/04-pipes/src/app.module' 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile() 13 | 14 | app = moduleFixture.createNestApplication() 15 | await app.init() 16 | }) 17 | 18 | afterEach(async () => { 19 | await app.close() 20 | }) 21 | 22 | it('/ (GET)', () => { 23 | return request(app.getHttpServer()).get('/').expect(200) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/categorias.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { CategoriasService } from './categorias.service' 3 | import { CategoriasController } from './categorias.controller' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { CategoriaEntity } from './entities/categoria.entity' 6 | import { CategoriasMapper } from './mappers/categorias.mapper' 7 | import { CacheModule } from '@nestjs/cache-manager' 8 | 9 | @Module({ 10 | // Importamos el servicio de categorias de TypeORM 11 | imports: [ 12 | TypeOrmModule.forFeature([CategoriaEntity]), 13 | CacheModule.register(), 14 | ], 15 | controllers: [CategoriasController], 16 | providers: [CategoriasService, CategoriasMapper], 17 | exports: [], 18 | }) 19 | export class CategoriasModule {} 20 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/mappers/usuarios.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { UserSignUpDto } from '../dto/user-sign.up.dto' 3 | import { Role } from '../../users/entities/user-role.entity' 4 | import { CreateUserDto } from '../../users/dto/create-user.dto' 5 | 6 | @Injectable() 7 | export class AuthMapper { 8 | toCreateDto(userSignUpDto: UserSignUpDto): CreateUserDto { 9 | const userCreateDto = new CreateUserDto() 10 | userCreateDto.nombre = userSignUpDto.nombre 11 | userCreateDto.apellidos = userSignUpDto.apellidos 12 | userCreateDto.username = userSignUpDto.username 13 | userCreateDto.email = userSignUpDto.email 14 | userCreateDto.password = userSignUpDto.password 15 | userCreateDto.roles = [Role.USER] 16 | return userCreateDto 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tienda-api-nest-js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | project: "tsconfig.json", 5 | tsconfigRootDir: __dirname, 6 | sourceType: "module" 7 | }, 8 | plugins: ["@typescript-eslint/eslint-plugin"], 9 | extends: [ 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:prettier/recommended" 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true 17 | }, 18 | ignorePatterns: [".eslintrc.js"], 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 | "@typescript-eslint/explicit-member-accessibility": "off", 25 | "semi": "off", 26 | "prefer-const": "error", 27 | "prefer-template": "error", 28 | "no-var": "error" 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/guards/roles-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | CanActivate, 4 | ExecutionContext, 5 | Injectable, 6 | } from '@nestjs/common' 7 | import { Observable } from 'rxjs' 8 | import { UsersService } from '../users.service' 9 | 10 | @Injectable() 11 | export class RolesExistsGuard implements CanActivate { 12 | constructor(private readonly usersService: UsersService) {} 13 | 14 | canActivate( 15 | context: ExecutionContext, 16 | ): boolean | Promise | Observable { 17 | const request = context.switchToHttp().getRequest() 18 | const roles = request.body.roles 19 | 20 | if (!roles || roles.length === 0) { 21 | throw new BadRequestException('El usuario debe tener al menos un rol') 22 | } 23 | 24 | // Lógica para verificar si los roles son válidos 25 | if (!this.usersService.validateRoles(roles)) { 26 | throw new BadRequestException('El usuario tiene roles inválidos') 27 | } 28 | 29 | return true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayNotEmpty, 3 | IsArray, 4 | IsEmail, 5 | IsNotEmpty, 6 | Matches, 7 | } from 'class-validator' 8 | 9 | export class CreateUserDto { 10 | @IsNotEmpty({ message: 'Nombre no puede estar vacío' }) 11 | nombre: string 12 | @IsNotEmpty({ message: 'Apellidos no puede estar vacío' }) 13 | apellidos: string 14 | @IsNotEmpty({ message: 'Username no puede estar vacío' }) 15 | username: string 16 | @IsEmail({}, { message: 'Email debe ser válido' }) 17 | @IsNotEmpty({ message: 'Email no puede estar vacío' }) 18 | email: string 19 | @IsArray({ message: 'Roles debe ser un array' }) 20 | @ArrayNotEmpty({ message: 'Roles no puede estar vacío' }) 21 | roles: string[] 22 | @IsNotEmpty({ message: 'Password no puede estar vacío' }) 23 | @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/, { 24 | message: 25 | 'Password no es válido, debe contener al menos 8 caracteres, una mayúscula, una minúscula y un número', 26 | }) 27 | password: string 28 | } 29 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { UsersService } from './users.service' 3 | import { UsersController } from './users.controller' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { Usuario } from './entities/user.entity' 6 | import { UserRole } from './entities/user-role.entity' 7 | import { UsuariosMapper } from './mappers/usuarios.mapper' 8 | import { CacheModule } from '@nestjs/cache-manager' 9 | import { BcryptService } from './bcrypt.service' 10 | import { PedidosModule } from '../pedidos/pedidos.module' 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([Usuario]), // Importamos el repositorio de usuarios 15 | TypeOrmModule.forFeature([UserRole]), // Importamos el repositorio de roles 16 | CacheModule.register(), // Importamos el módulo de cache 17 | PedidosModule, 18 | ], 19 | controllers: [UsersController], 20 | providers: [UsersService, UsuariosMapper, BcryptService], 21 | exports: [UsersService], 22 | }) 23 | export class UsersModule {} 24 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/dto/user-sign.up.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator' 2 | 3 | export class UserSignUpDto { 4 | @IsNotEmpty({ message: 'Nombre no puede estar vacío' }) 5 | @IsString({ message: 'Nombre no es válido' }) 6 | nombre: string 7 | 8 | @IsNotEmpty({ message: 'Apellidos no puede estar vacío' }) 9 | @IsString({ message: 'Apellidos no es válido' }) 10 | apellidos: string 11 | 12 | @IsNotEmpty({ message: 'Username no puede estar vacío' }) 13 | @IsString({ message: 'Username no es válido' }) 14 | username: string 15 | 16 | @IsEmail({}, { message: 'Email no es válido' }) 17 | @IsNotEmpty({ message: 'Email no puede estar vacío' }) 18 | email: string 19 | 20 | @IsString({ message: 'Password no es válido' }) 21 | @IsNotEmpty({ message: 'Password no puede estar vacío' }) 22 | @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/, { 23 | message: 24 | 'Password no es válido, debe contener al menos 8 caracteres, una mayúscula, una minúscula y un número', 25 | }) 26 | password: string 27 | } 28 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/strategies/jwt-strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { ExtractJwt, Strategy } from 'passport-jwt' 4 | import { Usuario } from '../../users/entities/user.entity' 5 | import { AuthService } from '../auth.service' 6 | 7 | @Injectable() 8 | export class JwtAuthStrategy extends PassportStrategy(Strategy) { 9 | constructor(private readonly authService: AuthService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // el token como barer token 12 | ignoreExpiration: false, // ignora la expiracion 13 | // La clave secreta 14 | secretOrKey: Buffer.from( 15 | process.env.TOKEN_SECRET || 16 | 'Me_Gustan_Los_Pepinos_De_Leganes_Porque_Son_Grandes_Y_Hermosos', 17 | 'utf-8', 18 | ).toString('base64'), 19 | }) 20 | } 21 | 22 | // Si se valida obtenemos el role 23 | async validate(payload: Usuario) { 24 | const id = payload.id 25 | return await this.authService.validateUser(id) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/guards/producto-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | CanActivate, 4 | ExecutionContext, 5 | Injectable, 6 | } from '@nestjs/common' 7 | import { ProductosService } from '../productos.service' 8 | import { Observable } from 'rxjs' 9 | 10 | @Injectable() 11 | export class ProductoExistsGuard implements CanActivate { 12 | constructor(private readonly productsService: ProductosService) {} 13 | 14 | canActivate( 15 | context: ExecutionContext, 16 | ): boolean | Promise | Observable { 17 | const request = context.switchToHttp().getRequest() 18 | const productId = parseInt(request.params.id, 10) 19 | 20 | // Lógica para verificar si el ID del producto es válido 21 | if (isNaN(productId)) { 22 | throw new BadRequestException('El id del producto no es válido') 23 | } 24 | return this.productsService.exists(productId).then((exists) => { 25 | if (!exists) { 26 | throw new BadRequestException('El ID del producto no existe') 27 | } 28 | return true 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tienda-api-nest-js/Dockerfile: -------------------------------------------------------------------------------- 1 | # Etapa de compilación, un docker especifico, que se etiqueta como build 2 | FROM node:16-alpine AS build 3 | 4 | # Directorio de trabajo 5 | WORKDIR /app 6 | 7 | # Copia el package.json 8 | COPY package*.json ./ 9 | 10 | # Instala las dependencias con ci es mas rapido y optimizado para docker 11 | # iestalamos todo porque vamos a hacer test, si no podríamos hacer npm ci --only=production 12 | RUN npm ci 13 | 14 | # Copia el resto de archivos del proyecto al directorio de trabajo 15 | COPY . . 16 | 17 | # Realiza los test 18 | RUN npm run test 19 | 20 | # Compila la aplicación 21 | RUN npm run build 22 | 23 | # Elimina las dependencias de prueba (devDependencies ya han pasado los test) 24 | RUN npm prune --production 25 | 26 | # Etapa de ejecución, un docker especifico, que se etiqueta como run 27 | FROM node:16-alpine AS run 28 | 29 | # Directorio de trabajo 30 | WORKDIR /app 31 | 32 | # Copia el node_modules 33 | COPY --from=build /app/node_modules/ /app/node_modules/ 34 | 35 | # Copia el directorio build de la etapa de compilación 36 | COPY --from=build /app/dist/ /app/dist/ 37 | 38 | # Copia el package.json 39 | COPY package*.json /app/ 40 | 41 | # Expone el puerto 3000 42 | EXPOSE 3000 43 | 44 | ENTRYPOINT ["npm", "run", "start:prod"] 45 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/entities/categoria.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | OneToMany, 6 | PrimaryColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm' 9 | import { ProductoEntity } from '../../productos/entities/producto.entity' 10 | 11 | @Entity({ name: 'categorias' }) // Case sensitive 12 | export class CategoriaEntity { 13 | @PrimaryColumn({ type: 'uuid' }) 14 | id: string 15 | 16 | @Column({ type: 'varchar', length: 255, unique: true }) 17 | nombre: string 18 | 19 | @CreateDateColumn({ 20 | name: 'created_at', 21 | type: 'timestamp', 22 | default: () => 'CURRENT_TIMESTAMP', 23 | }) 24 | createdAt: Date 25 | 26 | @UpdateDateColumn({ 27 | name: 'updated_at', 28 | type: 'timestamp', 29 | default: () => 'CURRENT_TIMESTAMP', 30 | onUpdate: 'CURRENT_TIMESTAMP', 31 | }) 32 | updatedAt: Date 33 | 34 | @Column({ name: 'is_deleted', type: 'boolean', default: false }) 35 | isDeleted: boolean 36 | 37 | // Relación uno a muchos con la entidad ProductoEntity 38 | // Un producto pertenece a una categoría 39 | // Una categoría tiene muchos productos 40 | // 1:N 41 | @OneToMany(() => ProductoEntity, (producto) => producto.categoria) 42 | productos: ProductoEntity[] 43 | } 44 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/guards/usuario-exists.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | CanActivate, 4 | ExecutionContext, 5 | Injectable, 6 | } from '@nestjs/common' 7 | import { Observable } from 'rxjs' 8 | import { PedidosService } from '../pedidos.service' 9 | 10 | @Injectable() 11 | export class UsuarioExistsGuard implements CanActivate { 12 | constructor(private readonly pedidosService: PedidosService) {} 13 | 14 | canActivate( 15 | context: ExecutionContext, 16 | ): boolean | Promise | Observable { 17 | const request = context.switchToHttp().getRequest() 18 | const body = request.body 19 | const idUsuario = body.idUsuario 20 | 21 | if (!idUsuario) { 22 | throw new BadRequestException('El id del usuario es obligatorio') 23 | } 24 | 25 | // Lógica para verificar si el ID del usuario es válido 26 | if (isNaN(idUsuario)) { 27 | throw new BadRequestException('El id del usuario no es válido') 28 | } 29 | 30 | return this.pedidosService.userExists(idUsuario).then((exists) => { 31 | if (!exists) { 32 | throw new BadRequestException( 33 | 'El ID del usuario no existe en el sistema', 34 | ) 35 | } 36 | return true 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/productos.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ProductosService } from './productos.service' 3 | import { ProductosController } from './productos.controller' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { ProductoEntity } from './entities/producto.entity' 6 | import { ProductosMapper } from './mappers/productos.mapper' 7 | import { CategoriaEntity } from '../categorias/entities/categoria.entity' 8 | import { NotificationsModule } from '../../websockets/notifications/notifications.module' 9 | import { StorageModule } from '../storage/storage.module' 10 | import { CacheModule } from '@nestjs/cache-manager' 11 | 12 | @Module({ 13 | // Importamos los repositorios (son modulos) a usar, que los crea automáticamente TypeORM 14 | imports: [ 15 | TypeOrmModule.forFeature([ProductoEntity]), // Importamos el repositorio de productos 16 | TypeOrmModule.forFeature([CategoriaEntity]), // Importamos el repositorio de categorias 17 | StorageModule, // Importamos el módulo de storage 18 | NotificationsModule, // Importamos el módulo de notificaciones 19 | CacheModule.register(), // Importamos el módulo de cache 20 | ], 21 | controllers: [ProductosController], 22 | providers: [ProductosService, ProductosMapper], 23 | }) 24 | export class ProductosModule {} 25 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/config/swagger/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' 2 | import { INestApplication } from '@nestjs/common' 3 | 4 | // Para evitar que un endpint salga: @ApiExcludeController() // Excluir el controlador de Swagger 5 | 6 | export function setupSwagger(app: INestApplication) { 7 | const config = new DocumentBuilder() 8 | .setTitle('API REST Tienda Nestjs DAW 2023/2024') 9 | .setDescription( 10 | 'API de ejemplo del curso Desarrollo de un API REST con Nestjs para 2º DAW. 2023/2024', 11 | ) 12 | .setContact( 13 | 'José Luis González Sánchez', 14 | 'https://joseluisgs.dev', 15 | 'joseluis.gonzalez@iesluisvives.org', 16 | ) 17 | .setExternalDoc( 18 | 'Documentación de la API', 19 | 'https://github.com/joseluisgs/DesarrolloWebEntornosServidor-03-2023-2024', 20 | ) 21 | .setLicense('CC BY-NC-SA 4.0', 'https://joseluisgs.dev/docs/license/') 22 | .setVersion('1.0.0') 23 | .addTag('Productos', 'Operaciones con productos') 24 | .addTag('Storage', 'Operaciones con almacenamiento') 25 | .addTag('Auth', 'Operaciones de autenticación') 26 | .addBearerAuth() // Añadimos el token de autenticación 27 | .build() 28 | 29 | const document = SwaggerModule.createDocument(app, config) 30 | SwaggerModule.setup('api', app, document) // http://localhost:3000/api 31 | } 32 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { AuthService } from './auth.service' 3 | import { AuthController } from './auth.controller' 4 | import { UsersModule } from '../users/users.module' 5 | import { AuthMapper } from './mappers/usuarios.mapper' 6 | import { JwtModule } from '@nestjs/jwt' 7 | import * as process from 'process' 8 | import { PassportModule } from '@nestjs/passport' 9 | import { JwtAuthStrategy } from './strategies/jwt-strategy' 10 | 11 | @Module({ 12 | imports: [ 13 | // Configuración edl servicio de JWT 14 | JwtModule.register({ 15 | // Lo voy a poner en base64 16 | secret: Buffer.from( 17 | process.env.TOKEN_SECRET || 18 | 'Me_Gustan_Los_Pepinos_De_Leganes_Porque_Son_Grandes_Y_Hermosos', 19 | 'utf-8', 20 | ).toString('base64'), 21 | signOptions: { 22 | expiresIn: Number(process.env.TOKEN_EXPIRES) || 3600, // Tiempo de expiracion 23 | algorithm: 'HS512', // Algoritmo de encriptacion 24 | }, 25 | }), 26 | // Importamos el módulo de passport con las estrategias 27 | PassportModule.register({ defaultStrategy: 'jwt' }), 28 | // Importamos el módulo de usuarios porque usaremos su servicio 29 | UsersModule, 30 | ], 31 | controllers: [AuthController], 32 | providers: [AuthService, AuthMapper, JwtAuthStrategy], 33 | }) 34 | export class AuthModule {} 35 | -------------------------------------------------------------------------------- /tienda-api-nest-js/database/tienda.js: -------------------------------------------------------------------------------- 1 | // Creamos el usuario administrador de la base de datos 2 | // con sus daatos de conexion y los roles que tendra 3 | db.createUser({ 4 | user: 'admin', 5 | pwd: 'adminPassword123', 6 | roles: [ 7 | { 8 | role: 'readWrite', 9 | db: 'tienda', 10 | }, 11 | ], 12 | }) 13 | 14 | // Nos conectamos a la base de datos world 15 | db = db.getSiblingDB('tienda') 16 | 17 | // Creamos la coleccion city 18 | db.createCollection('pedidos') 19 | 20 | // Insertamos los datos de la coleccion pedidos 21 | db.pedidos.insertMany([ 22 | { 23 | _id: ObjectId('6536518de9b0d305f193b5ef'), 24 | idUsuario: 1, 25 | cliente: { 26 | nombreCompleto: 'Juan Perez', 27 | email: 'juanperez@gmail.com', 28 | telefono: '+34123456789', 29 | direccion: { 30 | calle: 'Calle Mayor', 31 | numero: '10', 32 | ciudad: 'Madrid', 33 | provincia: 'Madrid', 34 | pais: 'España', 35 | codigoPostal: '28001', 36 | }, 37 | }, 38 | lineasPedido: [ 39 | { 40 | idProducto: 2, 41 | precioProducto: 19.99, 42 | cantidad: 1, 43 | total: 19.99, 44 | }, 45 | { 46 | idProducto: 3, 47 | precioProducto: 15.99, 48 | cantidad: 2, 49 | total: 31.98, 50 | }, 51 | ], 52 | createdAt: '2023-10-23T12:57:17.3411925', 53 | updatedAt: '2023-10-23T12:57:17.3411925', 54 | isDeleted: false, 55 | totalItems: 3, 56 | total: 51.97, 57 | }, 58 | ]) 59 | -------------------------------------------------------------------------------- /tienda-api-nest-js/cert/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIELTCCAxWgAwIBAgIUD+UeuoEwD30+hg1lNNUjMfwpd/EwDQYJKoZIhvcNAQEL 3 | BQAwgaUxCzAJBgNVBAYTAkVTMQ8wDQYDVQQIDAZNYWRyaWQxEzARBgNVBAcMCkxl 4 | Z2FuZXMNDQ0xFzAVBgNVBAoMDklFUyBMdWlzIFZpdmVzMQ8wDQYDVQQLDAZEQVcN 5 | DQ0xEzARBgNVBAMMCkpvc2VMdWlzR1MxMTAvBgkqhkiG9w0BCQEWImpvc2VsdWlz 6 | LmdvbnphbGV6QGllc2x1aXN2aXZlcy5vcmcwHhcNMjMxMTE4MDgyMzQ3WhcNMjQx 7 | MTE3MDgyMzQ3WjCBpTELMAkGA1UEBhMCRVMxDzANBgNVBAgMBk1hZHJpZDETMBEG 8 | A1UEBwwKTGVnYW5lcw0NDTEXMBUGA1UECgwOSUVTIEx1aXMgVml2ZXMxDzANBgNV 9 | BAsMBkRBVw0NDTETMBEGA1UEAwwKSm9zZUx1aXNHUzExMC8GCSqGSIb3DQEJARYi 10 | am9zZWx1aXMuZ29uemFsZXpAaWVzbHVpc3ZpdmVzLm9yZzCCASIwDQYJKoZIhvcN 11 | AQEBBQADggEPADCCAQoCggEBAK8DUJhTlSqhF+kmBvJgb999aUNszWnb4JfxSwhU 12 | 59gfmsxilaLnX180Je8Vr3i2P7VGq4uvLshRaa8Z0WF+Z3aSZxzzGm+SBpTAC2MN 13 | 9037u/3ccugfcl407VsOJWPtlRocFEgmlwWpfPx861jGtKiqwz7etXuinIupGF5i 14 | 9tca9R7/J7aqHQZD4wib3oDBzgZHbeSpU9M1IUfr7H6n2V3s/ySTMAZ8usWGp8Cr 15 | 70CWerPywW7gJMCCgJewB8QbF9Cv5l7/iM972u8mdnwh3fPCWwwccxQWXOkb3QqW 16 | Ij7xMUPXpP5dMH2G9O4x77XbX/c9ycytaExKu4WoS8eouYUCAwEAAaNTMFEwHQYD 17 | VR0OBBYEFDiWKENGbTiJNoV4cOk1YvX+MhWEMB8GA1UdIwQYMBaAFDiWKENGbTiJ 18 | NoV4cOk1YvX+MhWEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB 19 | AHzAsn1FU/tzymVOCb+9hgG5rn1GCgXHqN1oOSCBpQtrX17v/R9+LcvKV4ooowXG 20 | UDldDctCZ5MQRnhu1VSRYoYrsjeFfnM1kPct5L9xbbRWvehbSOlYHppligQYjnHN 21 | jIsmwC3G9Ot6c0iXQwzQqPCx2j1ecV9Lk93bjlZaMNCw/TMG5z+BWYXD7G4UnCA1 22 | EnEqP4313lqZ4Ptd4mL9ujYrQf6CD6Kl9Yzw0fO3qSHBE3ZTkcfcByB1cX6sB3Tf 23 | b8RntSRGi9c2gDeOUgysjmGEwnpFJPl2X6LVuvRJACph90DgOtsws1MlIzaY6lqn 24 | lZ9pnfCF3Pw1BwQ2QKy/N0k= 25 | -----END CERTIFICATE----- 26 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pedidos.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PedidosService } from './pedidos.service' 3 | import { PedidosController } from './pedidos.controller' 4 | import { MongooseModule, SchemaFactory } from '@nestjs/mongoose' 5 | import { Pedido } from './schemas/pedido.schema' 6 | import * as mongoosePaginate from 'mongoose-paginate-v2' 7 | import { TypeOrmModule } from '@nestjs/typeorm' 8 | import { ProductoEntity } from '../productos/entities/producto.entity' 9 | import { PedidosMapper } from './mappers/pedidos.mapper' 10 | import { CacheModule } from '@nestjs/cache-manager' 11 | import { Usuario } from '../users/entities/user.entity' 12 | 13 | @Module({ 14 | // El primer paso es en el módulo del recurso a paginar, debemos importar el plugin de paginación 15 | // Esto lo hacemos así porque ya vamos a añadir el plugin de paginación a todos los esquemas 16 | imports: [ 17 | MongooseModule.forFeatureAsync([ 18 | { 19 | name: Pedido.name, 20 | useFactory: () => { 21 | const schema = SchemaFactory.createForClass(Pedido) 22 | schema.plugin(mongoosePaginate) 23 | return schema 24 | }, 25 | }, 26 | ]), 27 | TypeOrmModule.forFeature([ProductoEntity]), // Importamos el repositorio de productos 28 | CacheModule.register(), // Importamos el módulo de cache 29 | TypeOrmModule.forFeature([Usuario]), // Importamos el repositorio de usuarios 30 | ], 31 | controllers: [PedidosController], 32 | providers: [PedidosService, PedidosMapper], 33 | exports: [PedidosService], 34 | }) 35 | export class PedidosModule {} 36 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ProductosModule } from './rest/productos/productos.module' 3 | import { ConfigModule } from '@nestjs/config' 4 | import { CategoriasModule } from './rest/categorias/categorias.module' 5 | import { StorageModule } from './rest/storage/storage.module' 6 | import { NotificationsModule } from './websockets/notifications/notifications.module' 7 | import { CacheModule } from '@nestjs/cache-manager' 8 | import { DatabaseModule } from './config/database/database.module' 9 | import { PedidosModule } from './rest/pedidos/pedidos.module' 10 | import { AuthModule } from './rest/auth/auth.module' 11 | import { UsersModule } from './rest/users/users.module' 12 | import { CorsConfigModule } from './config/cors/cors.module' 13 | 14 | @Module({ 15 | imports: [ 16 | // Lo primero es cargar la configuración de la aplicación y que esta esté disponible en el módulo raíz 17 | ConfigModule.forRoot( 18 | process.env.NODE_ENV === 'dev' 19 | ? { envFilePath: '.env.dev' || '.env' } 20 | : { envFilePath: '.env.prod' }, 21 | ), 22 | CorsConfigModule, // Configurar el módulo de cors 23 | DatabaseModule, // Configurar el módulo de base de datos 24 | CacheModule.register(), // Configurar el módulo de caché 25 | AuthModule, // Inyectamos el modulo de autenticacion (JWT y Guards) 26 | // Luego se cargan los módulos de la aplicación 27 | ProductosModule, 28 | CategoriasModule, 29 | StorageModule, 30 | NotificationsModule, 31 | PedidosModule, 32 | UsersModule, 33 | ], 34 | providers: [], 35 | }) 36 | export class AppModule {} 37 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/dto/response-producto.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class ResponseProductoDto { 4 | @ApiProperty({ example: 1, description: 'ID del producto' }) 5 | id: number 6 | 7 | @ApiProperty({ example: 'Nike', description: 'Marca del producto' }) 8 | marca: string 9 | 10 | @ApiProperty({ example: 'Air Max 90', description: 'Modelo del producto' }) 11 | modelo: string 12 | 13 | @ApiProperty({ 14 | example: 'Zapatillas deportivas', 15 | description: 'Descripción del producto', 16 | }) 17 | descripcion: string 18 | 19 | @ApiProperty({ example: 99.99, description: 'Precio del producto' }) 20 | precio: number 21 | 22 | @ApiProperty({ example: 10, description: 'Cantidad disponible en stock' }) 23 | stock: number 24 | 25 | @ApiProperty({ 26 | example: 'https://example.com/image.jpg', 27 | description: 'URL de la imagen del producto', 28 | }) 29 | imagen: string 30 | 31 | @ApiProperty({ example: 'abc123', description: 'UUID único del producto' }) 32 | uuid: string 33 | 34 | @ApiProperty({ 35 | example: '2023-09-01T12:34:56Z', 36 | description: 'Fecha y hora de creación del producto', 37 | }) 38 | createdAt: Date 39 | 40 | @ApiProperty({ 41 | example: '2023-09-02T10:20:30Z', 42 | description: 'Fecha y hora de actualización del producto', 43 | }) 44 | updatedAt: Date 45 | 46 | @ApiProperty({ 47 | example: false, 48 | description: 'Indica si el producto ha sido eliminado', 49 | }) 50 | isDeleted: boolean 51 | 52 | @ApiProperty({ example: 'Calzado', description: 'Categoría del producto' }) 53 | categoria: string 54 | } 55 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Logger, Post } from '@nestjs/common' 2 | import { AuthService } from './auth.service' 3 | import { UserSignUpDto } from './dto/user-sign.up.dto' 4 | import { UserSignInDto } from './dto/user-sign.in.dto' 5 | import { 6 | ApiBadRequestResponse, 7 | ApiBody, 8 | ApiExcludeEndpoint, 9 | ApiInternalServerErrorResponse, 10 | ApiResponse, 11 | ApiTags, 12 | } from '@nestjs/swagger' 13 | 14 | @Controller('auth') 15 | @ApiTags('Auth') 16 | export class AuthController { 17 | private readonly logger = new Logger(AuthController.name) 18 | 19 | constructor(private readonly authService: AuthService) {} 20 | 21 | @Post('signup') 22 | @ApiExcludeEndpoint() // Excluir el endpoint de Swagger 23 | async singUp(@Body() userSignUpDto: UserSignUpDto) { 24 | this.logger.log(`singUp: ${JSON.stringify(userSignUpDto)}`) 25 | return await this.authService.singUp(userSignUpDto) 26 | } 27 | 28 | @Post('signin') 29 | @ApiResponse({ 30 | status: 200, 31 | description: 32 | 'El usuario se ha logueado correctamente devolviendo el token de acceso', 33 | type: String, 34 | }) 35 | @ApiBody({ 36 | description: 'Credenciales de acceso', 37 | type: UserSignInDto, 38 | }) 39 | @ApiInternalServerErrorResponse({ 40 | description: 'Error interno de la api en bases de datos', 41 | }) 42 | @ApiBadRequestResponse({ 43 | description: 'Error en los datos de entrada', 44 | }) 45 | async singIn(@Body() userSignInDto: UserSignInDto) { 46 | this.logger.log(`singIn: ${JSON.stringify(userSignInDto)}`) 47 | return await this.authService.singIn(userSignInDto) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/mappers/productos.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { ProductoEntity } from '../entities/producto.entity' 3 | import { plainToClass } from 'class-transformer' 4 | import { CreateProductoDto } from '../dto/create-producto.dto' 5 | import { CategoriaEntity } from '../../categorias/entities/categoria.entity' 6 | import { v4 as uuidv4 } from 'uuid' 7 | import { ResponseProductoDto } from '../dto/response-producto.dto' 8 | import { ProductoNotificacionResponse } from '../../../websockets/notifications/dto/producto-notificacion.dto' 9 | 10 | @Injectable() 11 | export class ProductosMapper { 12 | toEntity( 13 | createProductoDto: CreateProductoDto, 14 | categoria: CategoriaEntity, 15 | ): ProductoEntity { 16 | const productoEntity = plainToClass(ProductoEntity, createProductoDto) 17 | productoEntity.categoria = categoria 18 | productoEntity.uuid = uuidv4() 19 | return productoEntity 20 | } 21 | 22 | toResponseDto(productoEntity: ProductoEntity): ResponseProductoDto { 23 | const dto = plainToClass(ResponseProductoDto, productoEntity) 24 | if (productoEntity.categoria && productoEntity.categoria.nombre) { 25 | dto.categoria = productoEntity.categoria.nombre 26 | } else { 27 | dto.categoria = null 28 | } 29 | return dto 30 | } 31 | 32 | toNotificacionDto( 33 | productoEntity: ProductoEntity, 34 | ): ProductoNotificacionResponse { 35 | const dto = plainToClass(ProductoNotificacionResponse, productoEntity) 36 | if (productoEntity.categoria && productoEntity.categoria.nombre) { 37 | dto.categoria = productoEntity.categoria.nombre 38 | } else { 39 | dto.categoria = null 40 | } 41 | return dto 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | OneToMany, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm' 9 | import { UserRole } from './user-role.entity' 10 | 11 | @Entity({ name: 'usuarios' }) // Nombre de la tabla (es case sensitive!!!) 12 | export class Usuario { 13 | @PrimaryGeneratedColumn({ type: 'bigint' }) // Autoincremental, le pongo bigint porque en postgresql el tipo serial es bigint 14 | id: number 15 | 16 | @Column({ type: 'varchar', length: 255, nullable: false }) 17 | nombre: string 18 | 19 | @Column({ type: 'varchar', length: 255, nullable: false }) 20 | apellidos: string 21 | 22 | @Column({ type: 'varchar', length: 255, nullable: false, unique: true }) 23 | email: string 24 | 25 | @Column({ unique: true, length: 255, nullable: false }) 26 | username: string 27 | 28 | @Column({ type: 'varchar', length: 255, nullable: false }) 29 | password: string 30 | 31 | @CreateDateColumn({ 32 | name: 'created_at', 33 | type: 'timestamp', 34 | default: () => 'CURRENT_TIMESTAMP', 35 | }) 36 | createdAt: Date 37 | 38 | @UpdateDateColumn({ 39 | name: 'updated_at', 40 | type: 'timestamp', 41 | default: () => 'CURRENT_TIMESTAMP', 42 | onUpdate: 'CURRENT_TIMESTAMP', 43 | }) 44 | updatedAt: Date 45 | 46 | @Column({ name: 'is_deleted', default: false }) 47 | isDeleted: boolean 48 | 49 | // Con el eager: true, cuando hagamos un find, nos traerá también los roles ahorrando una consulta (compo hicimos en producto con la categoría) 50 | @OneToMany(() => UserRole, (userRole) => userRole.usuario, { eager: true }) 51 | roles: UserRole[] 52 | 53 | get roleNames(): string[] { 54 | return this.roles.map((role) => role.role) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tienda-api-nest-js/cert/keystore.p12: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvA1CYU5UqoRfp 3 | JgbyYG/ffWlDbM1p2+CX8UsIVOfYH5rMYpWi519fNCXvFa94tj+1RquLry7IUWmv 4 | GdFhfmd2kmcc8xpvkgaUwAtjDfdN+7v93HLoH3JeNO1bDiVj7ZUaHBRIJpcFqXz8 5 | fOtYxrSoqsM+3rV7opyLqRheYvbXGvUe/ye2qh0GQ+MIm96Awc4GR23kqVPTNSFH 6 | 6+x+p9ld7P8kkzAGfLrFhqfAq+9Alnqz8sFu4CTAgoCXsAfEGxfQr+Ze/4jPe9rv 7 | JnZ8Id3zwlsMHHMUFlzpG90KliI+8TFD16T+XTB9hvTuMe+121/3PcnMrWhMSruF 8 | qEvHqLmFAgMBAAECggEAKBt/XdlRxvoyC5lOYD9fg/0uoWgOzyK3nrqKcndaxnm/ 9 | tPUmy7VwctX1l4wFsYk6omV2rMVR2FBoAtvM0yXvugmlHcpMlaMGZRJ5yZKKyVFW 10 | bMAXiUCjqhlBB+v+/56T2+7xavOFCqQj440RNkAbaMfxhLfvKU1DyPHQhtjxLqw5 11 | rGP/Ej3jj+/Sy2V7ZNooYGYV55j5R1EjsbRWOZ/baOn7ZUg04/RyYfB0CKxB4V5p 12 | tsasiiMz+2BZo6raNxAgNu1gypQdA7jjjdjgpxv+LnZ38sa5DIShQxznpF9bPBYn 13 | Ei2z+44vixfTuvE6kasANQ5HIgOkV+zHiPl2n8kuJQKBgQD3V+r0uYj9mdTSDKXm 14 | b/neVXc7ibyVqCCtkoLIKmqeUU4QDEA+09UMCzAnDGSNx40M+IqmGtN9AwMbssJU 15 | f8JfrKH9QgJgG1Bxupa+pt3os886PHai6K+ErDRyellDpgk58UcXTTBRlxJfzaFs 16 | 74xcsFLVGm+ZuP+t6t537+BLEwKBgQC1I1l6wvSJQmlCMIpIIzs+CZN+d4Lt2v64 17 | JbQBTVWjfQbEbTt32emfyjFPlAHt/7AcbH+W6IiSJrpwIGyryR80WO9jPfBMAccH 18 | HYsOZ71mMyNGBC+tPXU8Bi+5xGVOoITHMtj+CCLqKYnbmHF2ibze1Wxkl5qTwpuz 19 | 3dOioSgkBwKBgQC90YvwCd8W+JJ6LNi4syCoPyDE4VyH4cJKUpDpepveKfllmXFI 20 | hhsPJhrrLLiSkh0uYiNNfHLnkoM1I4e9f1q1P/AFQz49cVjYHuEHKVpN9ohHYhWN 21 | ylLA18NcQ4bzwp18CS2MtWEqjGy+dzm2N1SZ4XuALcyNxYr6drAKjV2tXwKBgBhs 22 | tNMV2K1tdA4Fx4+kmIdr+SRzbwctoW0pQFVwnRyXbkMsS3mEu7jdJbsKRRxXfuLG 23 | SooJvuieKkOWS7D6RKflWhoyruVA1BqEhEyj9mkCej4lsFwWzmkSmHrfHZ31jRHj 24 | LFlMtZCHm1wt+Ra3yezuMFh8DM7hzeb6AWCLhIMfAoGAVa3qEFsOWtXUC1uKuoNg 25 | vN99Xd59CIEd8PGUc8FFKO8oqPFWK8y5PmYGsSU6bM+R8C+5BYlYc2JXV9rWg1zG 26 | up5Y4CFGM6J+Ve3FpDhhkhRQHYIATDTtYAvHd0prSOVwZp5QBf4Cw+/MRmBsvu8a 27 | YZXz0uJmdNc/HmxvnQTdlto= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/dto/update-producto.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types' 2 | import { CreateProductoDto } from './create-producto.dto' 3 | import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator' 4 | import { ApiProperty } from '@nestjs/swagger' 5 | 6 | export class UpdateProductoDto extends PartialType(CreateProductoDto) { 7 | @ApiProperty({ 8 | example: 'Marca Ejemplo', 9 | description: 'La marca del producto', 10 | }) 11 | @IsOptional() 12 | @IsString() 13 | marca?: string 14 | 15 | @ApiProperty({ 16 | example: 'Modelo Ejemplo', 17 | description: 'El modelo del producto', 18 | }) 19 | @IsOptional() 20 | @IsString() 21 | modelo?: string 22 | 23 | @ApiProperty({ 24 | example: 'Descripción Ejemplo', 25 | description: 'La descripción del producto', 26 | }) 27 | @IsOptional() 28 | @IsString() 29 | descripcion?: string 30 | 31 | @ApiProperty({ example: 9.99, description: 'El precio del producto' }) 32 | @IsOptional() 33 | @IsNumber() 34 | @Min(0) 35 | precio?: number 36 | 37 | @ApiProperty({ example: 10, description: 'El stock del producto' }) 38 | @IsOptional() 39 | @IsNumber() 40 | @Min(0) 41 | stock?: number 42 | 43 | @ApiProperty({ 44 | example: 'imagen.jpg', 45 | description: 'La URL de la imagen del producto', 46 | }) 47 | @IsOptional() 48 | @IsString() 49 | imagen?: string 50 | 51 | @ApiProperty({ 52 | example: 'Electrónicos', 53 | description: 'El nombre de la categoría del producto', 54 | }) 55 | @IsOptional() 56 | @IsString() 57 | categoria?: string 58 | 59 | @ApiProperty({ 60 | example: true, 61 | description: 'Indica si el producto ha sido eliminado', 62 | }) 63 | @IsOptional() 64 | @IsBoolean() 65 | isDeleted?: boolean 66 | } 67 | -------------------------------------------------------------------------------- /tienda-api-nest-js/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Lo necesario para ejecutar la aplicación en local 2 | services: 3 | 4 | # PostgresSQL 5 | postgres-db: 6 | container_name: tienda-db_postgres 7 | image: postgres:12-alpine 8 | restart: always 9 | env_file: .env.prod 10 | environment: 11 | POSTGRES_USER: ${DATABASE_USER} 12 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 13 | POSTGRES_DB: ${POSTGRES_DATABASE} 14 | ports: 15 | - ${POSTGRES_PORT}:5432 16 | volumes: 17 | - ./database/tienda.sql:/docker-entrypoint-initdb.d/tienda.sql 18 | networks: 19 | - tienda-network 20 | 21 | # MongoDB 22 | mongo-db: 23 | container_name: tienda-db_mongo 24 | image: mongo:5.0 25 | restart: always 26 | env_file: .env.prod 27 | environment: 28 | MONGO_INITDB_ROOT_USERNAME: ${DATABASE_USER} 29 | MONGO_INITDB_ROOT_PASSWORD: ${DATABASE_PASSWORD} 30 | MONGO_INITDB_DATABASE: ${MONGO_DATABASE} 31 | ports: 32 | - ${MONGO_PORT}:27017 33 | volumes: 34 | - ./database/tienda.js:/docker-entrypoint-initdb.d/tienda.js:ro 35 | networks: 36 | - tienda-network 37 | 38 | # Servicio de la API REST 39 | tienda-api-rest: 40 | build: 41 | context: . 42 | dockerfile: Dockerfile 43 | container_name: tienda-api-rest 44 | restart: always 45 | env_file: .env.prod 46 | ports: 47 | - ${API_PORT}:3000 48 | volumes: 49 | - storage-dir:/app/storage-dir 50 | - ./cert:/app/cert 51 | networks: 52 | - tienda-network 53 | depends_on: 54 | - postgres-db 55 | - mongo-db 56 | 57 | # Volume para guardar los datos de la api rest, como las imágenes 58 | volumes: 59 | storage-dir: 60 | 61 | # Red para conectar los contenedores (opcional) 62 | networks: 63 | tienda-network: 64 | driver: bridge -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/dto/create-pedido.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsNumber, 5 | IsString, 6 | MaxLength, 7 | Min, 8 | } from 'class-validator' 9 | 10 | export class DireccionDto { 11 | @IsString() 12 | @MaxLength(100) 13 | @IsNotEmpty() 14 | calle: string 15 | 16 | @IsString() 17 | @MaxLength(50) 18 | @IsNotEmpty() 19 | numero: string 20 | 21 | @IsString() 22 | @MaxLength(100) 23 | @IsNotEmpty() 24 | ciudad: string 25 | 26 | @IsString() 27 | @MaxLength(100) 28 | @IsNotEmpty() 29 | provincia: string 30 | 31 | @IsString() 32 | @MaxLength(100) 33 | @IsNotEmpty() 34 | pais: string 35 | 36 | @IsString() 37 | @MaxLength(100) 38 | @IsNotEmpty() 39 | codigoPostal: string 40 | } 41 | 42 | export class ClienteDto { 43 | @IsString() 44 | @MaxLength(100) 45 | @IsNotEmpty() 46 | nombreCompleto: string 47 | 48 | @IsString() 49 | @MaxLength(100) 50 | @IsEmail() 51 | email: string 52 | 53 | @IsString() 54 | @MaxLength(100) 55 | @IsNotEmpty() 56 | telefono: string 57 | 58 | @IsNotEmpty() 59 | direccion: DireccionDto 60 | } 61 | 62 | export class LineaPedidoDto { 63 | @IsNumber() 64 | @IsNotEmpty() 65 | idProducto: number 66 | 67 | @IsNumber() 68 | @IsNotEmpty() 69 | @Min(0, { message: 'El precio debe ser mayor que 0' }) 70 | precioProducto: number 71 | 72 | @IsNumber() 73 | @IsNotEmpty() 74 | @Min(1, { message: 'El stock debe ser mayor que 0' }) 75 | cantidad: number 76 | 77 | @IsNumber() 78 | @IsNotEmpty() 79 | @Min(0, { message: 'El stock debe ser mayor que 0' }) 80 | total: number 81 | } 82 | 83 | export class CreatePedidoDto { 84 | @IsNumber() 85 | @IsNotEmpty() 86 | idUsuario: number 87 | 88 | @IsNotEmpty() 89 | cliente: ClienteDto 90 | 91 | @IsNotEmpty() 92 | lineasPedido: LineaPedidoDto[] 93 | } 94 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/mappers/usuarios.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Usuario } from '../entities/user.entity' 3 | import { UserDto } from '../dto/user-response.dto' 4 | import { CreateUserDto } from '../dto/create-user.dto' 5 | import { UserRole } from '../entities/user-role.entity' 6 | 7 | @Injectable() 8 | export class UsuariosMapper { 9 | toResponseDto(user: Usuario): UserDto { 10 | const userDto = new UserDto() 11 | userDto.id = user.id 12 | userDto.nombre = user.nombre 13 | userDto.apellidos = user.apellidos 14 | userDto.username = user.username 15 | userDto.email = user.email 16 | userDto.createdAt = user.createdAt 17 | userDto.updatedAt = user.updatedAt 18 | userDto.isDeleted = user.isDeleted 19 | userDto.roles = user.roles.map((role) => role.role) 20 | return userDto 21 | } 22 | 23 | toResponseDtoWithRoles(user: Usuario, roles: UserRole[]): UserDto { 24 | const userDto = new UserDto() 25 | userDto.id = user.id 26 | userDto.nombre = user.nombre 27 | userDto.apellidos = user.apellidos 28 | userDto.username = user.username 29 | userDto.email = user.email 30 | userDto.createdAt = user.createdAt 31 | userDto.updatedAt = user.updatedAt 32 | userDto.isDeleted = user.isDeleted 33 | userDto.roles = roles.map((role) => role.role) 34 | return userDto 35 | } 36 | 37 | toEntity(createUserDto: CreateUserDto): Usuario { 38 | const usuario = new Usuario() 39 | usuario.nombre = createUserDto.nombre 40 | usuario.apellidos = createUserDto.apellidos 41 | usuario.email = createUserDto.email 42 | usuario.username = createUserDto.username 43 | usuario.password = createUserDto.password 44 | usuario.createdAt = new Date() 45 | usuario.updatedAt = new Date() 46 | usuario.isDeleted = false 47 | return usuario 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { AppModule } from './app.module' 3 | import * as process from 'process' 4 | import { ValidationPipe } from '@nestjs/common' 5 | import { setupSwagger } from './config/swagger/swagger.config' 6 | import { getSSLOptions } from './config/ssl/ssl.config' 7 | 8 | // Cargamos las variables de entorno con esta librería porque las necesitamos 9 | // antes de iniciar la aplicación (el propio nest nos permite hacerlo con su módulo config) 10 | // Pero como necesitamos las variables antes de iniciar la aplicación y no tenemos su módulo aún cargado 11 | // usamos esta librería para cargarlas antes de iniciar la aplicación 12 | import * as dotenv from 'dotenv' 13 | 14 | dotenv.config() // Cargamos las variables de entorno 15 | 16 | async function bootstrap() { 17 | // Mostramos el modo de la aplicación 18 | if (process.env.NODE_ENV === 'dev') { 19 | console.log('🛠️ Iniciando Nestjs Modo desarrollo 🛠️') 20 | } else { 21 | console.log('🚗 Iniciando Nestjs Modo producción 🚗') 22 | } 23 | 24 | // Obtener las opciones de SSL 25 | const httpsOptions = getSSLOptions() 26 | 27 | // Inicialización de la aplicación 28 | const app = await NestFactory.create(AppModule, { httpsOptions }) 29 | 30 | // Configuración de la versión de la API 31 | app.setGlobalPrefix(process.env.API_VERSION || 'v1') 32 | 33 | // Configuración de Swagger solo en modo desarrollo 34 | if (process.env.NODE_ENV === 'dev') { 35 | setupSwagger(app) 36 | } 37 | // Activamos las validaciones body y dtos 38 | app.useGlobalPipes(new ValidationPipe()) 39 | 40 | // Configuración del puerto de escucha 41 | await app.listen(process.env.API_PORT || 3000) 42 | } 43 | 44 | // Inicialización de la aplicación y cuando esté lista se muestra un mensaje en consola 45 | bootstrap().then(() => 46 | console.log( 47 | `🟢 Servidor escuchando en puerto: ${ 48 | process.env.API_PORT || 3000 49 | } y perfil: ${process.env.NODE_ENV} 🚀`, 50 | ), 51 | ) 52 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/entities/producto.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm' 10 | import { CategoriaEntity } from '../../categorias/entities/categoria.entity' 11 | 12 | @Entity({ name: 'productos' }) // Nombre de la tabla (es case sensitive!!!) 13 | export class ProductoEntity { 14 | public static IMAGE_DEFAULT = 'https://via.placeholder.com/150' 15 | @PrimaryGeneratedColumn({ type: 'bigint' }) // Autoincremental, le pongo bigint porque en postgresql el tipo serial es bigint 16 | id: number 17 | @Column({ type: 'varchar', length: 255, nullable: false }) 18 | marca: string 19 | @Column({ type: 'varchar', length: 255, nullable: false }) 20 | modelo: string 21 | @Column({ type: 'varchar', length: 255, nullable: true }) 22 | descripcion: string 23 | @Column({ type: 'double precision', default: 0.0 }) 24 | precio: number 25 | @Column({ type: 'integer', default: 0 }) 26 | stock: number 27 | @Column({ type: 'text', default: ProductoEntity.IMAGE_DEFAULT }) 28 | imagen: string 29 | @Column({ type: 'uuid' }) 30 | uuid: string 31 | @CreateDateColumn({ 32 | name: 'created_at', 33 | type: 'timestamp', 34 | default: () => 'CURRENT_TIMESTAMP', 35 | }) 36 | createdAt: Date 37 | @UpdateDateColumn({ 38 | name: 'updated_at', 39 | type: 'timestamp', 40 | default: () => 'CURRENT_TIMESTAMP', 41 | onUpdate: 'CURRENT_TIMESTAMP', 42 | }) 43 | updatedAt: Date 44 | 45 | // Relación muchos a uno con la entidad CategoriaEntity 46 | // Un producto pertenece a una categoría 47 | // Una categoría tiene muchos productos 48 | @Column({ name: 'is_deleted', type: 'boolean', default: false }) 49 | isDeleted: boolean 50 | // N:1 51 | @ManyToOne(() => CategoriaEntity, (categoria) => categoria.productos) 52 | @JoinColumn({ name: 'categoria_id' }) // Especifica el nombre de la columna 53 | categoria: CategoriaEntity 54 | } 55 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/websockets/notifications/products-notifications.gateway.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets' 2 | import { Server, Socket } from 'socket.io' 3 | import { Logger } from '@nestjs/common' 4 | import * as process from 'process' 5 | import { Notificacion } from './models/notificacion.model' 6 | import { ResponseProductoDto } from '../../rest/productos/dto/response-producto.dto' 7 | 8 | const ENDPOINT: string = `/ws/${process.env.API_VERSION || 'v1'}/productos` 9 | 10 | @WebSocketGateway({ 11 | namespace: ENDPOINT, 12 | }) 13 | export class ProductsNotificationsGateway { 14 | @WebSocketServer() 15 | private server: Server 16 | 17 | private readonly logger = new Logger(ProductsNotificationsGateway.name) 18 | 19 | constructor() { 20 | this.logger.log(`ProductsNotificationsGateway is listening on ${ENDPOINT}`) 21 | } 22 | 23 | sendMessage(notification: Notificacion) { 24 | this.server.emit('updates', notification) 25 | } 26 | 27 | // Si quiero leer lo que llega y reenviarlo 28 | /*@SubscribeMessage('updateProduct') 29 | handleUpdateProduct(client: Socket, data: any) { 30 | // Aquí puedes manejar la lógica para procesar la actualización del producto 31 | // y enviar la notificación a todos los clientes conectados 32 | const notification = { 33 | message: 'Se ha actualizado un producto', 34 | data: data, 35 | } 36 | 37 | this.server.emit('updates', notification) 38 | }*/ 39 | 40 | private handleConnection(client: Socket) { 41 | // Este método se ejecutará cuando un cliente se conecte al WebSocket 42 | this.logger.debug('Cliente conectado:', client.id) 43 | this.server.emit( 44 | 'connection', 45 | 'Updates Notifications WS: Productos - Tienda API NestJS', 46 | ) 47 | } 48 | 49 | private handleDisconnect(client: Socket) { 50 | // Este método se ejecutará cuando un cliente se desconecte del WebSocket 51 | console.log('Cliente desconectado:', client.id) 52 | this.logger.debug('Cliente desconectado:', client.id) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/dto/create-producto.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsNumber, 4 | IsOptional, 5 | IsString, 6 | Length, 7 | Min, 8 | } from 'class-validator' 9 | import { ApiProperty } from '@nestjs/swagger' 10 | 11 | export class CreateProductoDto { 12 | @ApiProperty({ 13 | example: 'Nike', 14 | description: 'La marca del producto', 15 | minLength: 3, 16 | maxLength: 100, 17 | }) 18 | @IsString() 19 | @IsNotEmpty() 20 | @Length(3, 100, { message: 'El nombre debe tener entre 3 y 100 caracteres' }) 21 | marca: string 22 | 23 | @ApiProperty({ 24 | example: 'Air Max', 25 | description: 'El modelo del producto', 26 | minLength: 3, 27 | maxLength: 100, 28 | }) 29 | @IsString() 30 | @IsNotEmpty() 31 | @Length(3, 100, { message: 'El nombre debe tener entre 3 y 100 caracteres' }) 32 | modelo: string 33 | 34 | @ApiProperty({ 35 | example: 'Zapatillas deportivas', 36 | description: 'La descripción del producto', 37 | minLength: 1, 38 | maxLength: 100, 39 | }) 40 | @IsString() 41 | @IsNotEmpty() 42 | @Length(1, 100, { message: 'El nombre debe tener entre 1 y 100 caracteres' }) 43 | descripcion: string 44 | 45 | @ApiProperty({ 46 | example: 99.99, 47 | description: 'El precio del producto', 48 | minimum: 0, 49 | }) 50 | @IsNumber() 51 | @Min(0, { message: 'El precio debe ser mayor que 0' }) 52 | precio: number 53 | 54 | @ApiProperty({ 55 | example: 10, 56 | description: 'El stock del producto', 57 | minimum: 0, 58 | }) 59 | @IsNumber() 60 | @Min(0, { message: 'El stock debe ser mayor que 0' }) 61 | stock: number 62 | 63 | @ApiProperty({ 64 | example: 'https://example.com/imagen.jpg', 65 | description: 'La URL de la imagen del producto', 66 | required: false, 67 | }) 68 | @IsOptional() 69 | @IsString() 70 | imagen?: string 71 | 72 | @ApiProperty({ 73 | example: 'Calzado', 74 | description: 'La categoría del producto', 75 | }) 76 | @IsString() 77 | @IsNotEmpty() 78 | categoria: string // No es el id, si no el nombre de la categoria 79 | } 80 | -------------------------------------------------------------------------------- /tienda-api-nest-js/docker-compose-db.yaml: -------------------------------------------------------------------------------- 1 | # Servicios de almacenamiento de datos 2 | # Desarrollo 3 | services: 4 | # PostgreSQL 5 | postgres-db: 6 | container_name: tienda-db_postgres 7 | image: postgres:12-alpine 8 | restart: always 9 | env_file: .env 10 | environment: 11 | POSTGRES_USER: ${DATABASE_USER} 12 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 13 | POSTGRES_DB: ${POSTGRES_DATABASE} 14 | ports: 15 | - ${POSTGRES_PORT}:5432 16 | volumes: 17 | - ./database/tienda.sql:/docker-entrypoint-initdb.d/tienda.sql 18 | networks: 19 | - tienda-network 20 | 21 | # MongoDB 22 | mongo-db: 23 | container_name: tienda-db_mongo 24 | image: mongo:5.0 25 | restart: always 26 | env_file: .env 27 | environment: 28 | MONGO_INITDB_ROOT_USERNAME: ${DATABASE_USER} 29 | MONGO_INITDB_ROOT_PASSWORD: ${DATABASE_PASSWORD} 30 | MONGO_INITDB_DATABASE: ${MONGO_DATABASE} 31 | ports: 32 | - ${MONGO_PORT}:27017 33 | volumes: 34 | - ./database/tienda.js:/docker-entrypoint-initdb.d/tienda.js:ro 35 | networks: 36 | - tienda-network 37 | 38 | # Adminer para conectarse a la base de datos 39 | # Quitar en despliegue final 40 | adminer-postgres-db: 41 | container_name: tienda-db_adminer-postgres-db 42 | image: adminer 43 | restart: always 44 | env_file: .env.prod 45 | ports: 46 | - 8080:8080 47 | depends_on: 48 | - postgres-db 49 | networks: 50 | - tienda-network 51 | 52 | # Mongo Express para conectarse a la base de datos 53 | # Quitar en despliegue final 54 | mongo-express-db: 55 | container_name: tienda-db_mongo-express-db 56 | image: mongo-express 57 | restart: always 58 | env_file: .env.prod 59 | ports: 60 | - 8081:8081 61 | environment: 62 | ME_CONFIG_MONGODB_ADMINUSERNAME: ${DATABASE_USER} 63 | ME_CONFIG_MONGODB_ADMINPASSWORD: ${DATABASE_PASSWORD} 64 | ME_CONFIG_MONGODB_SERVER: mongo-db 65 | depends_on: 66 | - mongo-db 67 | networks: 68 | - tienda-network 69 | 70 | networks: 71 | tienda-network: 72 | driver: bridge -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | InternalServerErrorException, 5 | Logger, 6 | } from '@nestjs/common' 7 | import { UserSignUpDto } from './dto/user-sign.up.dto' 8 | import { UserSignInDto } from './dto/user-sign.in.dto' 9 | import { UsersService } from '../users/users.service' 10 | import { AuthMapper } from './mappers/usuarios.mapper' 11 | import { JwtService } from '@nestjs/jwt' 12 | 13 | @Injectable() 14 | export class AuthService { 15 | private readonly logger = new Logger(AuthService.name) 16 | 17 | constructor( 18 | private readonly usersService: UsersService, 19 | private readonly authMapper: AuthMapper, 20 | private readonly jwtService: JwtService, 21 | ) {} 22 | 23 | async singUp(userSignUpDto: UserSignUpDto) { 24 | this.logger.log(`singUp ${userSignUpDto.username}`) 25 | 26 | const user = await this.usersService.create( 27 | this.authMapper.toCreateDto(userSignUpDto), 28 | ) 29 | return this.getAccessToken(user.id) 30 | } 31 | 32 | async singIn(userSignInDto: UserSignInDto) { 33 | this.logger.log(`singIn ${userSignInDto.username}`) 34 | const user = await this.usersService.findByUsername(userSignInDto.username) 35 | if (!user) { 36 | throw new BadRequestException('username or password are invalid') 37 | } 38 | const isValidPassword = await this.usersService.validatePassword( 39 | userSignInDto.password, // plain 40 | user.password, // hash 41 | ) 42 | if (!isValidPassword) { 43 | throw new BadRequestException('username or password are invalid') 44 | } 45 | return this.getAccessToken(user.id) 46 | } 47 | 48 | async validateUser(id: number) { 49 | this.logger.log(`validateUser ${id}`) 50 | return await this.usersService.findOne(id) 51 | } 52 | 53 | private getAccessToken(userId: number) { 54 | this.logger.log(`getAccessToken ${userId}`) 55 | try { 56 | const payload = { 57 | id: userId, 58 | } 59 | //console.log(payload) 60 | const access_token = this.jwtService.sign(payload) 61 | return { 62 | access_token, 63 | } 64 | } catch (error) { 65 | this.logger.error(error) 66 | throw new InternalServerErrorException('Error al generar el token') 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/config/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'process' 2 | import { Logger, Module } from '@nestjs/common' 3 | import { ConfigModule } from '@nestjs/config' 4 | import { TypeOrmModule } from '@nestjs/typeorm' 5 | import { MongooseModule } from '@nestjs/mongoose' 6 | import * as path from 'path' 7 | 8 | @Module({ 9 | imports: [ 10 | // Configurar el módulo de base de datos de Postgres asíncronamente 11 | // TypeOrm 12 | TypeOrmModule.forRootAsync({ 13 | imports: [ConfigModule], 14 | useFactory: async () => ({ 15 | type: 'postgres', 16 | host: process.env.POSTGRES_HOST || 'localhost', 17 | port: parseInt(process.env.POSTGRES_PORT) || 5432, 18 | username: process.env.DATABASE_USER, 19 | password: process.env.DATABASE_PASSWORD, 20 | database: process.env.POSTGRES_DATABASE, 21 | autoLoadEntities: true, 22 | //entities: [`${__dirname}/**/*.entity{.ts,.js}`], // Cargamos todas las entidades 23 | // Entities no estan en config ahora, si no en /rest 24 | entities: [ 25 | path.join(__dirname), 26 | '../../dist/rest/**/*.entity{.ts,.js}', 27 | ], // Cargamos todas las entidades, 28 | synchronize: process.env.NODE_ENV === 'dev', // Esto es para que se sincronicen las entidades con la base de datos 29 | //synchronize: true, // Esto es para que se sincronicen las entidades con la base de datos 30 | logging: process.env.NODE_ENV === 'dev' ? 'all' : false, // Esto es para que se muestren los logs de las consultas 31 | retryAttempts: 5, 32 | connectionFactory: (connection) => { 33 | Logger.log('Postgres database connected', 'DatabaseModule') 34 | return connection 35 | }, 36 | }), 37 | }), 38 | // Configurar MongoDB 39 | MongooseModule.forRootAsync({ 40 | imports: [ConfigModule], 41 | useFactory: async () => ({ 42 | uri: `mongodb://${process.env.DATABASE_USER}:${ 43 | process.env.DATABASE_PASSWORD 44 | }@${process.env.MONGO_HOST}:${process.env.MONGO_PORT || 27017}/${ 45 | process.env.MONGO_DATABASE 46 | }`, 47 | retryAttempts: 5, 48 | connectionFactory: (connection) => { 49 | Logger.log( 50 | `MongoDB readyState: ${connection.readyState}`, 51 | 'DatabaseModule', 52 | ) 53 | return connection 54 | }, 55 | }), 56 | }), 57 | ], 58 | exports: [TypeOrmModule], 59 | }) 60 | export class DatabaseModule {} 61 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/auth/guards/roles-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | Logger, 6 | SetMetadata, 7 | } from '@nestjs/common' 8 | import { Reflector } from '@nestjs/core' 9 | 10 | /** 11 | * RolesAuthGuard es un guardián personalizado que implementa la interfaz CanActivate de NestJS. 12 | * Los guardianes son responsables de determinar si una solicitud debe ser manejada por la ruta o no. 13 | * 14 | * El constructor de RolesAuthGuard inyecta una instancia de Reflector, que es 15 | * una utilidad proporcionada por NestJS para recuperar metadatos. 16 | * 17 | * La función canActivate es el corazón del guardián. Se llama cada vez que una 18 | * solicitud entra en una ruta que está protegida por este guardián. 19 | * 20 | * Dentro de canActivate, primero usamos Reflector para obtener los roles requeridos 21 | * del manejador de ruta. Estos roles son metadatos que se agregaron al manejador de ruta utilizando el decorador @Roles. 22 | * 23 | * Si no se requieren roles para la ruta, permitimos que la solicitud pase. 24 | * 25 | * Luego, obtenemos el objeto de usuario de la solicitud. Este objeto de usuario 26 | * debe haber sido adjuntado a la solicitud por un middleware o guardián anterior, como JwtAuthGuard. 27 | * 28 | * Comprobamos si el usuario tiene alguno de los roles requeridos. Si es así, 29 | * permitimos que la solicitud pase. Si no, la solicitud es denegada. 30 | */ 31 | 32 | @Injectable() 33 | export class RolesAuthGuard implements CanActivate { 34 | private readonly logger = new Logger(RolesAuthGuard.name) 35 | 36 | constructor(private reflector: Reflector) {} 37 | 38 | canActivate(context: ExecutionContext): boolean { 39 | const roles = this.reflector.get('roles', context.getHandler()) 40 | this.logger.log(`Roles: ${roles}`) 41 | if (!roles) { 42 | return true 43 | } 44 | const request = context.switchToHttp().getRequest() 45 | const user = request.user 46 | this.logger.log(`User roles: ${user.roles}`) 47 | // Al menos tenga un rol de los requeridos!! 48 | const hasRole = () => user.roles.some((role) => roles.includes(role)) 49 | return user && user.roles && hasRole() 50 | } 51 | } 52 | 53 | /* 54 | SetMetadata es una función proporcionada por NestJS que te permite agregar metadatos personalizados 55 | a los manejadores de ruta. Estamos creando una nueva función, Roles, que toma una lista de roles 56 | y utiliza SetMetadata para agregarlos como metadatos al manejador de ruta. 57 | Podrás poner los roles requeridos en la ruta usando el decorador @Roles. 58 | */ 59 | // El decorador 60 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles) 61 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/categorias.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Logger, 8 | Param, 9 | ParseUUIDPipe, 10 | Post, 11 | Put, 12 | UseGuards, 13 | UseInterceptors, 14 | } from '@nestjs/common' 15 | import { CategoriasService } from './categorias.service' 16 | import { CreateCategoriaDto } from './dto/create-categoria.dto' 17 | import { UpdateCategoriaDto } from './dto/update-categoria.dto' 18 | import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager' 19 | import { Paginate, PaginateQuery } from 'nestjs-paginate' 20 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' 21 | import { Roles, RolesAuthGuard } from '../auth/guards/roles-auth.guard' 22 | import { ApiExcludeController } from '@nestjs/swagger' 23 | 24 | @Controller('categorias') 25 | @UseInterceptors(CacheInterceptor) // Aplicar el interceptor aquí de cahce 26 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí para autenticados con JWT y Roles (lo aplico a nivel de controlador) 27 | @ApiExcludeController() 28 | export class CategoriasController { 29 | private readonly logger = new Logger(CategoriasController.name) 30 | 31 | constructor(private readonly categoriasService: CategoriasService) {} 32 | 33 | @Get() 34 | @CacheKey('all_categories') 35 | @CacheTTL(30) 36 | @Roles('USER') 37 | async findAll(@Paginate() query: PaginateQuery) { 38 | this.logger.log('Find all categorias') 39 | return await this.categoriasService.findAll(query) 40 | } 41 | 42 | @Get(':id') 43 | @Roles('USER') 44 | async findOne(@Param('id', ParseUUIDPipe) id: string) { 45 | this.logger.log(`Find one categoria by id:${id}`) 46 | return await this.categoriasService.findOne(id) 47 | } 48 | 49 | @Post() 50 | @HttpCode(201) 51 | @Roles('ADMIN') 52 | async create(@Body() createCategoriaDto: CreateCategoriaDto) { 53 | this.logger.log(`Create categoria ${createCategoriaDto}`) 54 | return await this.categoriasService.create(createCategoriaDto) 55 | } 56 | 57 | @Put(':id') 58 | @Roles('ADMIN') 59 | async update( 60 | @Param('id', ParseUUIDPipe) id: string, 61 | @Body() updateCategoriaDto: UpdateCategoriaDto, 62 | ) { 63 | this.logger.log(`Update categoria with id:${id} - ${updateCategoriaDto}`) 64 | return await this.categoriasService.update(id, updateCategoriaDto) 65 | } 66 | 67 | @Delete(':id') 68 | @HttpCode(204) 69 | @Roles('ADMIN') 70 | async remove(@Param('id', ParseUUIDPipe) id: string) { 71 | this.logger.log(`Remove categoria with id:${id}`) 72 | // Borrado fisico 73 | //await this.categoriasService.remove(id) 74 | // Borrado logico 75 | await this.categoriasService.removeSoft(id) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/mappers/categorias.mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { CategoriasMapper } from './categorias.mapper' 3 | import { CategoriaEntity } from '../entities/categoria.entity' 4 | import { CreateCategoriaDto } from '../dto/create-categoria.dto' 5 | import { UpdateCategoriaDto } from '../dto/update-categoria.dto' 6 | 7 | describe('CategoriasMapper', () => { 8 | let provider: CategoriasMapper 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [CategoriasMapper], 13 | }).compile() 14 | 15 | provider = module.get(CategoriasMapper) 16 | }) 17 | 18 | it('should be defined', () => { 19 | expect(provider).toBeDefined() 20 | }) 21 | 22 | describe('CategoriasMapper', () => { 23 | let categoriasMapper: CategoriasMapper 24 | 25 | beforeEach(async () => { 26 | const module: TestingModule = await Test.createTestingModule({ 27 | providers: [CategoriasMapper], 28 | }).compile() 29 | 30 | categoriasMapper = module.get(CategoriasMapper) 31 | }) 32 | 33 | it('should be defined', () => { 34 | expect(categoriasMapper).toBeDefined() 35 | }) 36 | 37 | it('should map CreateCategoriaDto to CategoriaEntity', () => { 38 | const createCategoriaDto: CreateCategoriaDto = { 39 | nombre: 'Categoria 1', 40 | } 41 | 42 | const expectedCategoriaEntity: CategoriaEntity = { 43 | id: '51310e5f-4b47-4994-9f66-975bbdacdd35', 44 | nombre: 'Categoria 1', 45 | createdAt: new Date(), 46 | updatedAt: new Date(), 47 | isDeleted: false, 48 | productos: [], 49 | } 50 | 51 | const actualCategoriaEntity: CategoriaEntity = 52 | categoriasMapper.toEntity(createCategoriaDto) 53 | 54 | expect(actualCategoriaEntity.nombre).toEqual( 55 | expectedCategoriaEntity.nombre, 56 | ) 57 | }) 58 | 59 | it('should map UpdateCategoriaDto to CategoriaEntity', () => { 60 | const updateCategoriaDto: UpdateCategoriaDto = { 61 | nombre: 'Categoria 1', 62 | isDeleted: true, 63 | } 64 | 65 | const expectedCategoriaEntity: CategoriaEntity = { 66 | id: '51310e5f-4b47-4994-9f66-975bbdacdd35', 67 | nombre: 'Categoria 1', 68 | createdAt: new Date(), 69 | updatedAt: new Date(), 70 | isDeleted: true, 71 | productos: [], 72 | } 73 | 74 | const actualCategoriaEntity: CategoriaEntity = 75 | categoriasMapper.toEntity(updateCategoriaDto) 76 | 77 | expect(actualCategoriaEntity).toBeInstanceOf(CategoriaEntity) 78 | 79 | expect(actualCategoriaEntity.nombre).toEqual( 80 | expectedCategoriaEntity.nombre, 81 | ) 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/storage/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NotFoundException } from '@nestjs/common' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import { join } from 'path' 5 | 6 | @Injectable() 7 | export class StorageService { 8 | private readonly uploadsDir = process.env.UPLOADS_DIR || './storage-dir' 9 | private readonly isDev = process.env.NODE_ENV === 'dev' 10 | private readonly logger = new Logger(StorageService.name) 11 | 12 | // Este método se ejecuta cuando el módulo se inicia 13 | // En este caso, si estamos en entorno de desarrollo, se eliminan los archivos 14 | // del directorio de uploads y se crea de nuevo. 15 | // Esto es para que cada vez que se inicie el servidor, el directorio esté vacío. 16 | async onModuleInit() { 17 | if (this.isDev) { 18 | if (fs.existsSync(this.uploadsDir)) { 19 | this.logger.log(`Eliminando ficheros de ${this.uploadsDir}`) 20 | fs.readdirSync(this.uploadsDir).forEach((file) => { 21 | fs.unlinkSync(path.join(this.uploadsDir, file)) 22 | }) 23 | } else { 24 | this.logger.log( 25 | `Creando directorio de subida de archivos en ${this.uploadsDir}`, 26 | ) 27 | fs.mkdirSync(this.uploadsDir) 28 | } 29 | } 30 | } 31 | 32 | findFile(filename: string): string { 33 | this.logger.log(`Buscando fichero ${filename}`) 34 | const file = join( 35 | process.cwd(), // process.cwd() devuelve el directorio de trabajo actual 36 | process.env.UPLOADS_DIR || './storage-dir', // directorio de subida de archivos 37 | filename, // nombre del archivo 38 | ) 39 | if (fs.existsSync(file)) { 40 | this.logger.log(`Fichero encontrado ${file}`) 41 | return file 42 | } else { 43 | throw new NotFoundException(`El fichero ${filename} no existe.`) 44 | } 45 | } 46 | 47 | getFileNameWithouUrl(fileUrl: string): string { 48 | try { 49 | const url = new URL(fileUrl) 50 | const pathname = url.pathname // '/v1/storage/bd9e0f33-21b4-4abd-9659-069b6fcf7fb4.png' 51 | const segments = pathname.split('/') 52 | const filename = segments[segments.length - 1] // 'bd9e0f33-21b4-4abd-9659-069b6fcf7fb4.png' 53 | return filename 54 | } catch (error) { 55 | this.logger.error(error) 56 | return fileUrl 57 | } 58 | } 59 | 60 | removeFile(filename: string): void { 61 | this.logger.log(`Eliminando fichero ${filename}`) 62 | const file = join( 63 | process.cwd(), // process.cwd() devuelve el directorio de trabajo actual 64 | process.env.UPLOADS_DIR || './storage-dir', // directorio de subida de archivos 65 | filename, // nombre del archivo 66 | ) 67 | if (fs.existsSync(file)) { 68 | fs.unlinkSync(file) 69 | } else { 70 | throw new NotFoundException(`El fichero ${filename} no existe.`) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tienda-api-nest-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tienda-api-nest-js", 3 | "version": "0.0.1", 4 | "description": "Ejemplo de la API de tienda NestJS", 5 | "author": "joseluisgs", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "cross-env NODE_ENV=dev nest start", 12 | "start:dev": "cross-env NODE_ENV=dev nest start --watch", 13 | "start:debug": "cross-env NODE_ENV=dev nest start --debug --watch", 14 | "start:prod": "cross-env NODE_ENV=prod node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "cross-env NODE_ENV=dev node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/cache-manager": "^2.1.1", 24 | "@nestjs/common": "^10.0.0", 25 | "@nestjs/config": "^3.1.1", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/jwt": "^10.2.0", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/mongoose": "^10.0.2", 30 | "@nestjs/passport": "^10.0.2", 31 | "@nestjs/platform-express": "^10.0.0", 32 | "@nestjs/platform-socket.io": "^10.2.8", 33 | "@nestjs/swagger": "^7.1.16", 34 | "@nestjs/typeorm": "^10.0.0", 35 | "@nestjs/websockets": "^10.2.8", 36 | "bcryptjs": "^2.4.3", 37 | "cache-manager": "^5.2.4", 38 | "class-transformer": "^0.5.1", 39 | "class-validator": "^0.14.0", 40 | "cross-env": "^7.0.3", 41 | "dotenv": "^16.3.1", 42 | "mongoose": "^8.0.0", 43 | "mongoose-paginate-v2": "^1.7.4", 44 | "nestjs-paginate": "^8.5.0", 45 | "passport": "^0.6.0", 46 | "passport-jwt": "^4.0.1", 47 | "pg": "^8.11.3", 48 | "reflect-metadata": "^0.1.13", 49 | "rxjs": "^7.8.1", 50 | "typeorm": "^0.3.17", 51 | "uuid": "^9.0.1" 52 | }, 53 | "devDependencies": { 54 | "@nestjs/cli": "^10.0.0", 55 | "@nestjs/schematics": "^10.0.0", 56 | "@nestjs/testing": "^10.0.0", 57 | "@types/express": "^4.17.17", 58 | "@types/jest": "^29.5.2", 59 | "@types/multer": "^1.4.10", 60 | "@types/node": "^20.3.1", 61 | "@types/passport-jwt": "^3.0.13", 62 | "@types/supertest": "^2.0.12", 63 | "@types/uuid": "^9.0.6", 64 | "@typescript-eslint/eslint-plugin": "^6.0.0", 65 | "@typescript-eslint/parser": "^6.0.0", 66 | "eslint": "^8.42.0", 67 | "eslint-config-prettier": "^9.0.0", 68 | "eslint-plugin-prettier": "^5.0.0", 69 | "jest": "^29.5.0", 70 | "prettier": "^3.0.0", 71 | "source-map-support": "^0.5.21", 72 | "supertest": "^6.3.3", 73 | "ts-jest": "^29.1.0", 74 | "ts-loader": "^9.4.3", 75 | "ts-node": "^10.9.1", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^5.1.3" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tienda-api-nest-js/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pedidos.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | DefaultValuePipe, 5 | Delete, 6 | Get, 7 | HttpCode, 8 | Logger, 9 | Param, 10 | ParseIntPipe, 11 | Post, 12 | Put, 13 | Query, 14 | UseGuards, 15 | UseInterceptors, 16 | } from '@nestjs/common' 17 | import { UpdatePedidoDto } from './dto/update-pedido.dto' 18 | import { CreatePedidoDto } from './dto/create-pedido.dto' 19 | import { OrderByValidatePipe } from './pipes/orderby-validate.pipe' 20 | import { PedidosService } from './pedidos.service' 21 | import { OrderValidatePipe } from './pipes/order-validate.pipe' 22 | import { IdValidatePipe } from './pipes/id-validate.pipe' 23 | import { CacheInterceptor } from '@nestjs/cache-manager' 24 | import { UsuarioExistsGuard } from './guards/usuario-exists.guard' 25 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' 26 | import { Roles, RolesAuthGuard } from '../auth/guards/roles-auth.guard' 27 | import { ApiExcludeController } from '@nestjs/swagger' 28 | 29 | @Controller('pedidos') 30 | @UseInterceptors(CacheInterceptor) // Aplicar el interceptor aquí de cache 31 | @UseGuards(JwtAuthGuard, RolesAuthGuard) 32 | @ApiExcludeController() 33 | export class PedidosController { 34 | private readonly logger = new Logger(PedidosController.name) 35 | 36 | constructor(private readonly pedidosService: PedidosService) {} 37 | 38 | @Get() 39 | @Roles('ADMIN') 40 | async findAll( 41 | @Query('page', new DefaultValuePipe(1)) page: number = 1, 42 | @Query('limit', new DefaultValuePipe(20)) limit: number = 20, 43 | @Query('orderBy', new DefaultValuePipe('idUsuario'), OrderByValidatePipe) 44 | orderBy: string = 'idUsuario', 45 | @Query('order', new DefaultValuePipe('asc'), OrderValidatePipe) 46 | order: string, 47 | ) { 48 | this.logger.log( 49 | `Buscando todos los pedidos con: ${JSON.stringify({ 50 | page, 51 | limit, 52 | orderBy, 53 | order, 54 | })}`, 55 | ) 56 | return await this.pedidosService.findAll(page, limit, orderBy, order) 57 | } 58 | 59 | @Get(':id') 60 | @Roles('ADMIN') 61 | async findOne(@Param('id', IdValidatePipe) id: string) { 62 | this.logger.log(`Buscando pedido con id ${id}`) 63 | return await this.pedidosService.findOne(id) 64 | } 65 | 66 | @Get('usuario/:idUsuario') 67 | @Roles('ADMIN') 68 | async findPedidosPorUsuario( 69 | @Param('idUsuario', ParseIntPipe) idUsuario: number, 70 | ) { 71 | this.logger.log(`Buscando pedidos por usuario ${idUsuario}`) 72 | return await this.pedidosService.findByIdUsuario(idUsuario) 73 | } 74 | 75 | @Post() 76 | @Roles('ADMIN') 77 | @HttpCode(201) 78 | @UseGuards(UsuarioExistsGuard) // Aplicar el guard aquí 79 | async create(@Body() createPedidoDto: CreatePedidoDto) { 80 | this.logger.log(`Creando pedido ${JSON.stringify(createPedidoDto)}`) 81 | return await this.pedidosService.create(createPedidoDto) 82 | } 83 | 84 | @Put(':id') 85 | @UseGuards(UsuarioExistsGuard) // Aplicar el guard aquí 86 | @Roles('ADMIN') 87 | async update( 88 | @Param('id', IdValidatePipe) id: string, 89 | @Body() updatePedidoDto: UpdatePedidoDto, 90 | ) { 91 | this.logger.log( 92 | `Actualizando pedido con id ${id} y ${JSON.stringify(updatePedidoDto)}`, 93 | ) 94 | return await this.pedidosService.update(id, updatePedidoDto) 95 | } 96 | 97 | @Delete(':id') 98 | @HttpCode(204) 99 | @Roles('ADMIN') 100 | async remove(@Param('id', IdValidatePipe) id: string) { 101 | this.logger.log(`Eliminando pedido con id ${id}`) 102 | await this.pedidosService.remove(id) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/mappers/productos.mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { ProductosMapper } from './productos.mapper' 3 | import { CreateProductoDto } from '../dto/create-producto.dto' 4 | import { ProductoEntity } from '../entities/producto.entity' 5 | import { CategoriaEntity } from '../../categorias/entities/categoria.entity' 6 | import { ResponseProductoDto } from '../dto/response-producto.dto' 7 | import { v4 as uuidv4 } from 'uuid' 8 | 9 | describe('ProductosMapper', () => { 10 | let productosMapper: ProductosMapper 11 | 12 | const categoriaEntity: CategoriaEntity = { 13 | id: '51310e5f-4b47-4994-9f66-975bbdacdd35', 14 | nombre: 'Categoria 1', 15 | createdAt: new Date(), 16 | updatedAt: new Date(), 17 | isDeleted: true, 18 | productos: [], 19 | } 20 | 21 | const createProductoDto: CreateProductoDto = { 22 | marca: 'Producto 1', 23 | modelo: 'Modelo del producto 1', 24 | descripcion: 'Descripción del producto 1', 25 | precio: 1000, 26 | stock: 10, 27 | imagen: 'https://www.google.com', 28 | categoria: categoriaEntity.id, 29 | } 30 | 31 | const productoEntity: ProductoEntity = { 32 | id: 1, 33 | marca: 'Producto 1', 34 | modelo: 'Modelo del producto 1', 35 | descripcion: 'Descripción del producto 1', 36 | precio: 1000, 37 | stock: 10, 38 | imagen: 'https://www.google.com', 39 | categoria: categoriaEntity, 40 | uuid: uuidv4(), 41 | createdAt: new Date(), 42 | updatedAt: new Date(), 43 | isDeleted: false, 44 | } 45 | 46 | beforeEach(async () => { 47 | const module: TestingModule = await Test.createTestingModule({ 48 | providers: [ProductosMapper], 49 | }).compile() 50 | 51 | productosMapper = module.get(ProductosMapper) 52 | }) 53 | 54 | it('should be defined', () => { 55 | expect(productosMapper).toBeDefined() 56 | }) 57 | 58 | it('should map CreateProductoDto to ProductoEntity', () => { 59 | const expectedProductoEntity: ProductoEntity = { 60 | ...productoEntity, 61 | categoria: categoriaEntity, 62 | } 63 | 64 | const actualProductoEntity: ProductoEntity = productosMapper.toEntity( 65 | createProductoDto, 66 | categoriaEntity, 67 | ) 68 | 69 | expect(actualProductoEntity).toBeInstanceOf(ProductoEntity) 70 | 71 | expect(actualProductoEntity.marca).toEqual(expectedProductoEntity.marca) 72 | expect(actualProductoEntity.modelo).toEqual(expectedProductoEntity.modelo) 73 | expect(actualProductoEntity.descripcion).toEqual( 74 | expectedProductoEntity.descripcion, 75 | ) 76 | expect(actualProductoEntity.precio).toEqual(expectedProductoEntity.precio) 77 | expect(actualProductoEntity.stock).toEqual(expectedProductoEntity.stock) 78 | expect(actualProductoEntity.imagen).toEqual(expectedProductoEntity.imagen) 79 | expect(actualProductoEntity.categoria).toEqual( 80 | expectedProductoEntity.categoria, 81 | ) 82 | }) 83 | 84 | it('should map ProductoEntity to ResponseProductoDto', () => { 85 | const expectedResponseProductoDto: ResponseProductoDto = { 86 | ...productoEntity, 87 | categoria: categoriaEntity.nombre, 88 | } 89 | 90 | const actualResponseProductoDto: ResponseProductoDto = 91 | productosMapper.toResponseDto(productoEntity) 92 | 93 | expect(actualResponseProductoDto).toBeInstanceOf(ResponseProductoDto) 94 | 95 | expect(actualResponseProductoDto.marca).toEqual(productoEntity.marca) 96 | expect(actualResponseProductoDto.modelo).toEqual(productoEntity.modelo) 97 | expect(actualResponseProductoDto.descripcion).toEqual( 98 | productoEntity.descripcion, 99 | ) 100 | expect(actualResponseProductoDto.precio).toEqual(productoEntity.precio) 101 | expect(actualResponseProductoDto.stock).toEqual(productoEntity.stock) 102 | expect(actualResponseProductoDto.imagen).toEqual(productoEntity.imagen) 103 | expect(actualResponseProductoDto.categoria).toEqual( 104 | expectedResponseProductoDto.categoria, 105 | ) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/schemas/pedido.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import * as mongoosePaginate from 'mongoose-paginate-v2' 3 | 4 | // Usamos clases embebidas, por lo que definimos antes las necesarias 5 | // Recursos adicionales 6 | export class Direccion { 7 | @Prop({ 8 | type: String, 9 | required: true, 10 | length: 100, 11 | default: '', 12 | }) 13 | calle: string 14 | 15 | @Prop({ 16 | type: String, 17 | required: true, 18 | length: 50, 19 | default: '', 20 | }) 21 | numero: string 22 | 23 | @Prop({ 24 | type: String, 25 | required: true, 26 | length: 100, 27 | default: '', 28 | }) 29 | ciudad: string 30 | 31 | @Prop({ 32 | type: String, 33 | required: true, 34 | length: 100, 35 | default: '', 36 | }) 37 | provincia: string 38 | 39 | @Prop({ 40 | type: String, 41 | required: true, 42 | length: 100, 43 | default: '', 44 | }) 45 | pais: string 46 | 47 | @Prop({ 48 | type: String, 49 | required: true, 50 | length: 100, 51 | default: '', 52 | }) 53 | codigoPostal: string 54 | } 55 | 56 | export class Cliente { 57 | @Prop({ 58 | type: String, 59 | required: true, 60 | length: 100, 61 | default: '', 62 | }) 63 | nombreCompleto: string 64 | 65 | @Prop({ 66 | type: String, 67 | required: true, 68 | length: 100, 69 | default: '', 70 | }) 71 | email: string 72 | 73 | @Prop({ 74 | type: String, 75 | required: true, 76 | length: 100, 77 | default: '', 78 | }) 79 | telefono: string 80 | 81 | @Prop({ 82 | required: true, 83 | }) 84 | direccion: Direccion 85 | } 86 | 87 | export class LineaPedido { 88 | @Prop({ 89 | type: Number, 90 | required: true, 91 | }) 92 | idProducto: number 93 | 94 | @Prop({ 95 | type: Number, 96 | required: true, 97 | }) 98 | precioProducto: number 99 | 100 | @Prop({ 101 | type: Number, 102 | required: true, 103 | }) 104 | cantidad: number 105 | 106 | @Prop({ 107 | type: Number, 108 | required: true, 109 | }) 110 | total: number 111 | } 112 | 113 | // Nuestro documento de la base de datos para poder usarlo en el servicio 114 | // y en el controlador, lo usaremos para mapear los datos de la base de datos 115 | export type PedidoDocument = Pedido & Document 116 | 117 | // El esquema de la base de datos 118 | @Schema({ 119 | collection: 'pedidos', // Nombre de la colección en la base de datos 120 | timestamps: false, // No queremos que se añadan los campos createdAt y updatedAt, los añadimos nosotros 121 | // Este método toJSON se ejecutará cada vez que se llame a JSON.stringify() en un documento de Mongoose 122 | // mapea el _id a id y elimina __v y _id cuando se llama a JSON.stringify() 123 | versionKey: false, 124 | id: true, 125 | toJSON: { 126 | virtuals: true, 127 | // Aquí añadimos el método toJSON 128 | transform: (doc, ret) => { 129 | delete ret.__v // Eliminamos el campo __v 130 | ret.id = ret._id // Mapeamos el _id a id 131 | delete ret._id // Eliminamos el _id 132 | delete ret._class // Esto es por si usamos discriminadores 133 | }, 134 | }, 135 | }) 136 | // Nuestra clase principal (esquema)!! 137 | // Definimos con @Prop() cada uno de los campos de la colección 138 | export class Pedido { 139 | @Prop({ 140 | type: Number, 141 | required: true, 142 | }) 143 | idUsuario: number 144 | 145 | @Prop({ 146 | required: true, 147 | }) 148 | cliente: Cliente 149 | 150 | @Prop({ 151 | required: true, 152 | }) 153 | lineasPedido: LineaPedido[] 154 | 155 | @Prop() 156 | totalItems: number 157 | 158 | @Prop() 159 | total: number 160 | 161 | @Prop({ default: Date.now }) 162 | createdAt: Date 163 | 164 | @Prop({ default: Date.now }) 165 | updatedAt: Date 166 | 167 | @Prop({ default: false }) 168 | isDeleted: boolean 169 | } 170 | 171 | // Genera el esquema de la base de datos a partir de la clase Pedido 172 | // le añado el plugin de paginación que hemos importado 173 | export const PedidoSchema = SchemaFactory.createForClass(Pedido) 174 | PedidoSchema.plugin(mongoosePaginate) 175 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/storage/storage.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | Get, 5 | Logger, 6 | Param, 7 | Req, 8 | Res, 9 | UploadedFile, 10 | UseInterceptors, 11 | } from '@nestjs/common' 12 | import { StorageService } from './storage.service' 13 | import { FileInterceptor } from '@nestjs/platform-express' 14 | import { diskStorage } from 'multer' 15 | import { extname } from 'path' 16 | import { v4 as uuidv4 } from 'uuid' 17 | import { Request, Response } from 'express' 18 | import { 19 | ApiNotFoundResponse, 20 | ApiParam, 21 | ApiResponse, 22 | ApiTags, 23 | } from '@nestjs/swagger' 24 | 25 | @Controller('storage') 26 | @ApiTags('Storage') // Aplicar el decorador en el controlador 27 | export class StorageController { 28 | private readonly logger = new Logger(StorageController.name) 29 | 30 | constructor(private readonly storageService: StorageService) {} 31 | 32 | // @Post() // no quiero que se pueda crear un fichero desde el controlador!! 33 | // El decorador @UseInterceptors nos permite interceptar la petición 34 | // y realizar acciones antes de que llegue al controlador 35 | // En este caso, usamos el interceptor FileInterceptor para interceptar 36 | // la petición y subir el archivo al servidor 37 | // Este interceptor recibe como parámetro el nombre del campo del formulario 38 | // que contiene el archivo 39 | // El interceptor FileInterceptor recibe un objeto de configuración 40 | // con las siguientes propiedades: 41 | // - storage: es el destino donde se almacenará el archivo 42 | // - filename: es el nombre del archivo 43 | // - fileFilter: es una función que se ejecuta antes de subir el archivo 44 | @UseInterceptors( 45 | FileInterceptor('file', { 46 | storage: diskStorage({ 47 | destination: process.env.UPLOADS_DIR || './storage-dir', 48 | filename: (req, file, cb) => { 49 | const fileName = uuidv4() // usamos uuid para generar un nombre único para el archivo 50 | const fileExt = extname(file.originalname) // extraemos la extensión del archivo 51 | cb(null, `${fileName}${fileExt}`) // llamamos al callback con el nombre del archivo 52 | }, 53 | }), 54 | // Validación de archivos 55 | fileFilter: (req, file, cb) => { 56 | if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { 57 | // Note: You can customize this error message to be more specific 58 | cb(new BadRequestException('Fichero no soportado.'), false) 59 | } else { 60 | cb(null, true) 61 | } 62 | }, 63 | }), 64 | ) // 'file' es el nombre del campo en el formulario 65 | storeFile(@UploadedFile() file: Express.Multer.File, @Req() req: Request) { 66 | this.logger.log(`Subiendo archivo: ${file}`) 67 | 68 | if (!file) { 69 | throw new BadRequestException('Fichero no encontrado.') 70 | } 71 | 72 | // Construimos la url del fichero, que será la url de la API + el nombre del fichero 73 | const apiVersion = process.env.API_VERSION 74 | ? `/${process.env.API_VERSION}` 75 | : '' 76 | const url = `${req.protocol}://${req.get('host')}${apiVersion}/storage/${ 77 | file.filename 78 | }` 79 | console.log(file) 80 | return { 81 | originalname: file.originalname, 82 | filename: file.filename, 83 | size: file.size, 84 | mimetype: file.mimetype, 85 | path: file.path, 86 | url: url, 87 | } 88 | } 89 | 90 | @Get(':filename') 91 | @ApiResponse({ 92 | status: 200, 93 | description: 94 | 'El fichero se ha encontrado y se devuelve el fichero en la respuesta', 95 | type: String, 96 | }) 97 | @ApiParam({ 98 | name: 'filename', 99 | description: 'Nombre del fichero', 100 | type: String, 101 | }) 102 | @ApiNotFoundResponse({ 103 | description: 'El fichero no existe', 104 | }) 105 | getFile(@Param('filename') filename: string, @Res() res: Response) { 106 | this.logger.log(`Buscando fichero ${filename}`) 107 | const filePath = this.storageService.findFile(filename) 108 | this.logger.log(`Fichero encontrado ${filePath}`) 109 | res.sendFile(filePath) // enviamos el archivo 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Logger, 8 | Param, 9 | ParseIntPipe, 10 | Post, 11 | Put, 12 | Req, 13 | UseGuards, 14 | UseInterceptors, 15 | } from '@nestjs/common' 16 | import { UsersService } from './users.service' 17 | import { CacheInterceptor } from '@nestjs/cache-manager' 18 | import { CreateUserDto } from './dto/create-user.dto' 19 | import { Roles, RolesAuthGuard } from '../auth/guards/roles-auth.guard' 20 | import { UpdateUserDto } from './dto/update-user.dto' 21 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' 22 | import { IdValidatePipe } from '../pedidos/pipes/id-validate.pipe' 23 | import { CreatePedidoDto } from '../pedidos/dto/create-pedido.dto' 24 | import { UpdatePedidoDto } from '../pedidos/dto/update-pedido.dto' 25 | import { ApiExcludeController } from '@nestjs/swagger' 26 | 27 | @Controller('users') 28 | @UseInterceptors(CacheInterceptor) // Aplicar el interceptor aquí de cache 29 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí para autenticados con JWT y Roles (lo aplico a nivel de controlador) 30 | @ApiExcludeController() 31 | export class UsersController { 32 | private readonly logger = new Logger(UsersController.name) 33 | 34 | constructor(private readonly usersService: UsersService) {} 35 | 36 | /// GESTION, SOLO ADMINISTRADOR 37 | 38 | @Get() 39 | @Roles('ADMIN') 40 | async findAll() { 41 | this.logger.log('findAll') 42 | return await this.usersService.findAll() 43 | } 44 | 45 | @Get(':id') 46 | @Roles('ADMIN') 47 | async findOne(id: number) { 48 | this.logger.log(`findOne: ${id}`) 49 | return await this.usersService.findOne(id) 50 | } 51 | 52 | @Post() 53 | @HttpCode(201) 54 | @Roles('ADMIN') 55 | async create(@Body() createUserDto: CreateUserDto) { 56 | this.logger.log('create') 57 | return await this.usersService.create(createUserDto) 58 | } 59 | 60 | @Put(':id') 61 | @Roles('ADMIN') 62 | async update( 63 | @Param('id', ParseIntPipe) id: number, 64 | @Body() updateUserDto: UpdateUserDto, 65 | ) { 66 | this.logger.log(`update: ${id}`) 67 | return await this.usersService.update(id, updateUserDto, true) 68 | } 69 | 70 | // ME/PROFILE, CUALQUIER USUARIO AUTENTICADO 71 | @Get('me/profile') 72 | @Roles('USER') 73 | async getProfile(@Req() request: any) { 74 | return request.user 75 | } 76 | 77 | @Delete('me/profile') 78 | @HttpCode(204) 79 | @Roles('USER') 80 | async deleteProfile(@Req() request: any) { 81 | return await this.usersService.deleteById(request.user.id) 82 | } 83 | 84 | @Put('me/profile') 85 | @Roles('USER') 86 | async updateProfile( 87 | @Req() request: any, 88 | @Body() updateUserDto: UpdateUserDto, 89 | ) { 90 | return await this.usersService.update(request.user.id, updateUserDto, false) 91 | } 92 | 93 | // ME/PEDIDOS, CUALQUIER USUARIO AUTENTICADO siempre y cuando el id del usuario coincida con el id del pedido 94 | @Get('me/pedidos') 95 | async getPedidos(@Req() request: any) { 96 | return await this.usersService.getPedidos(request.user.id) 97 | } 98 | 99 | @Get('me/pedidos/:id') 100 | async getPedido( 101 | @Req() request: any, 102 | @Param('id', IdValidatePipe) id: string, 103 | ) { 104 | return await this.usersService.getPedido(request.user.id, id) 105 | } 106 | 107 | @Post('me/pedidos') 108 | @HttpCode(201) 109 | @Roles('USER') 110 | async createPedido( 111 | @Body() createPedidoDto: CreatePedidoDto, 112 | @Req() request: any, 113 | ) { 114 | this.logger.log(`Creando pedido ${JSON.stringify(createPedidoDto)}`) 115 | return await this.usersService.createPedido( 116 | createPedidoDto, 117 | request.user.id, 118 | ) 119 | } 120 | 121 | @Put('me/pedidos/:id') 122 | @Roles('USER') 123 | async updatePedido( 124 | @Param('id', IdValidatePipe) id: string, 125 | @Body() updatePedidoDto: UpdatePedidoDto, 126 | @Req() request: any, 127 | ) { 128 | this.logger.log( 129 | `Actualizando pedido con id ${id} y ${JSON.stringify(updatePedidoDto)}`, 130 | ) 131 | return await this.usersService.updatePedido( 132 | id, 133 | updatePedidoDto, 134 | request.user.id, 135 | ) 136 | } 137 | 138 | @Delete('me/pedidos/:id') 139 | @HttpCode(204) 140 | @Roles('USER') 141 | async removePedido( 142 | @Param('id', IdValidatePipe) id: string, 143 | @Req() request: any, 144 | ) { 145 | this.logger.log(`Eliminando pedido con id ${id}`) 146 | await this.usersService.removePedido(id, request.user.id) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/categorias.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { CategoriasController } from './categorias.controller' 2 | import { CategoriasService } from './categorias.service' 3 | import { Test, TestingModule } from '@nestjs/testing' 4 | import { CategoriaEntity } from './entities/categoria.entity' 5 | import { NotFoundException } from '@nestjs/common' 6 | import { UpdateCategoriaDto } from './dto/update-categoria.dto' 7 | import { CreateCategoriaDto } from './dto/create-categoria.dto' 8 | import { Paginated } from 'nestjs-paginate' 9 | import { CacheModule } from '@nestjs/cache-manager' 10 | 11 | describe('CategoriasController', () => { 12 | let controller: CategoriasController 13 | let service: CategoriasService 14 | 15 | // Mi mock de servicio de categorias tendrá los siguientes métodos 16 | const mockCategoriaService = { 17 | findAll: jest.fn(), 18 | findOne: jest.fn(), 19 | create: jest.fn(), 20 | update: jest.fn(), 21 | removeSoft: jest.fn(), 22 | } 23 | 24 | beforeEach(async () => { 25 | // Creamos un módulo de prueba de NestJS que nos permitirá crear una instancia de nuestro controlador. 26 | const module: TestingModule = await Test.createTestingModule({ 27 | imports: [CacheModule.register()], // importamos el módulo de caché, lo necesita el controlador (interceptores y anotaciones) 28 | controllers: [CategoriasController], 29 | providers: [ 30 | { provide: CategoriasService, useValue: mockCategoriaService }, 31 | ], 32 | }).compile() 33 | 34 | controller = module.get(CategoriasController) 35 | service = module.get(CategoriasService) 36 | }) 37 | 38 | it('should be defined', () => { 39 | expect(controller).toBeDefined() 40 | }) 41 | 42 | describe('findAll', () => { 43 | it('should get all categorias', async () => { 44 | const paginateOptions = { 45 | page: 1, 46 | limit: 10, 47 | path: 'categorias', 48 | } 49 | 50 | const testCategories = { 51 | data: [], 52 | meta: { 53 | itemsPerPage: 10, 54 | totalItems: 1, 55 | currentPage: 1, 56 | totalPages: 1, 57 | }, 58 | links: { 59 | current: 'categorias?page=1&limit=10&sortBy=nombre:ASC', 60 | }, 61 | } as Paginated 62 | jest.spyOn(service, 'findAll').mockResolvedValue(testCategories) 63 | const result: any = await controller.findAll(paginateOptions) 64 | 65 | // console.log(result) 66 | expect(result.meta.itemsPerPage).toEqual(paginateOptions.limit) 67 | // Expect the result to have the correct currentPage 68 | expect(result.meta.currentPage).toEqual(paginateOptions.page) 69 | // Expect the result to have the correct totalPages 70 | expect(result.meta.totalPages).toEqual(1) // You may need to adjust this value based on your test case 71 | // Expect the result to have the correct current link 72 | expect(result.links.current).toEqual( 73 | `categorias?page=${paginateOptions.page}&limit=${paginateOptions.limit}&sortBy=nombre:ASC`, 74 | ) 75 | expect(service.findAll).toHaveBeenCalled() 76 | }) 77 | }) 78 | 79 | describe('findOne', () => { 80 | it('should get one categoria', async () => { 81 | const id = 'uuid' 82 | const mockResult: CategoriaEntity = new CategoriaEntity() 83 | 84 | jest.spyOn(service, 'findOne').mockResolvedValue(mockResult) 85 | await controller.findOne(id) 86 | expect(service.findOne).toHaveBeenCalledWith(id) 87 | expect(mockResult).toBeInstanceOf(CategoriaEntity) 88 | }) 89 | 90 | it('should throw NotFoundException if categoria does not exist', async () => { 91 | const id = 'a uuid' 92 | jest.spyOn(service, 'findOne').mockRejectedValue(new NotFoundException()) 93 | await expect(controller.findOne(id)).rejects.toThrow(NotFoundException) 94 | }) 95 | }) 96 | 97 | describe('create', () => { 98 | it('should create a categoria', async () => { 99 | const dto: CreateCategoriaDto = { 100 | nombre: 'test', 101 | } 102 | const mockResult: CategoriaEntity = new CategoriaEntity() 103 | jest.spyOn(service, 'create').mockResolvedValue(mockResult) 104 | await controller.create(dto) 105 | expect(service.create).toHaveBeenCalledWith(dto) 106 | }) 107 | }) 108 | 109 | describe('update', () => { 110 | it('should update a categoria', async () => { 111 | const id = 'a uuid' 112 | const dto: UpdateCategoriaDto = { 113 | nombre: 'test', 114 | isDeleted: true, 115 | } 116 | const mockResult: CategoriaEntity = new CategoriaEntity() 117 | jest.spyOn(service, 'update').mockResolvedValue(mockResult) 118 | await controller.update(id, dto) 119 | expect(service.update).toHaveBeenCalledWith(id, dto) 120 | expect(mockResult).toBeInstanceOf(CategoriaEntity) 121 | }) 122 | 123 | it('should throw NotFoundException if categoria does not exist', async () => { 124 | const id = 'a uuid' 125 | const dto: UpdateCategoriaDto = {} 126 | jest.spyOn(service, 'update').mockRejectedValue(new NotFoundException()) 127 | await expect(controller.update(id, dto)).rejects.toThrow( 128 | NotFoundException, 129 | ) 130 | }) 131 | }) 132 | 133 | describe('remove', () => { 134 | it('should remove a categoria', async () => { 135 | const id = 'a uuid' 136 | const mockResult: CategoriaEntity = new CategoriaEntity() 137 | jest.spyOn(service, 'removeSoft').mockResolvedValue(mockResult) 138 | await controller.remove(id) 139 | expect(service.removeSoft).toHaveBeenCalledWith(id) 140 | }) 141 | 142 | it('should throw NotFoundException if categoria does not exist', async () => { 143 | const id = 'a uuid' 144 | jest 145 | .spyOn(service, 'removeSoft') 146 | .mockRejectedValue(new NotFoundException()) 147 | await expect(controller.remove(id)).rejects.toThrow(NotFoundException) 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /tienda-api-nest-js/test/rest/categorias/categorias.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, NotFoundException } from '@nestjs/common' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | import { CategoriasService } from '../../../src/rest/categorias/categorias.service' 4 | import { CategoriaEntity } from '../../../src/rest/categorias/entities/categoria.entity' 5 | import * as request from 'supertest' 6 | import { CategoriasController } from '../../../src/rest/categorias/categorias.controller' 7 | import { CacheModule } from '@nestjs/cache-manager' 8 | import { RolesAuthGuard } from '../../../src/rest/auth/guards/roles-auth.guard' 9 | import { JwtAuthGuard } from '../../../src/rest/auth/guards/jwt-auth.guard' 10 | 11 | // https://blog.logrocket.com/end-end-testing-nestjs-typeorm/ 12 | 13 | describe('CategoriasController (e2e)', () => { 14 | let app: INestApplication 15 | const myEndpoint = `/categorias` 16 | 17 | const myCategoria: CategoriaEntity = { 18 | id: '7958ef01-9fe0-4f19-a1d5-79c917290ddf', 19 | nombre: 'nombre', 20 | isDeleted: false, 21 | createdAt: new Date(), 22 | updatedAt: new Date(), 23 | productos: [], 24 | } 25 | 26 | const createCategoriaDto = { 27 | nombre: 'nombre', 28 | } 29 | 30 | const updateCategoriaDto = { 31 | nombre: 'nombre', 32 | isDeleted: false, 33 | } 34 | 35 | // Mock de servicio y sus metodos 36 | const mockCategoriasService = { 37 | findAll: jest.fn(), 38 | findOne: jest.fn(), 39 | create: jest.fn(), 40 | update: jest.fn(), 41 | remove: jest.fn(), 42 | removeSoft: jest.fn(), 43 | } 44 | 45 | beforeEach(async () => { 46 | // Cargamos solo el controlador y el servicio que vamos a probar, no el módulo que arrastra con todo 47 | // No es de integración si no e2e, con mocks 48 | const moduleFixture: TestingModule = await Test.createTestingModule({ 49 | imports: [CacheModule.register()], // importamos el módulo de caché, lo necesita el controlador (interceptores y anotaciones) 50 | controllers: [CategoriasController], 51 | providers: [ 52 | CategoriasService, 53 | { provide: CategoriasService, useValue: mockCategoriasService }, 54 | ], 55 | }) 56 | // Le decimos a Nest que inyecte nuestro mock de servicio en lugar del servicio real. 57 | // Pero ya se lo hemos inyectado arriba 58 | //.overrideProvider(CategoriasService) 59 | //.useValue(mockCategoriasService) 60 | .overrideGuard(JwtAuthGuard) 61 | .useValue({ canActivate: () => true }) // Esto permite que todas las solicitudes pasen el JwtAuthGuard 62 | .overrideGuard(RolesAuthGuard) 63 | .useValue({ canActivate: () => true }) // Esto permite que todas las solicitudes pasen el RolesAuthGuard 64 | .compile() 65 | 66 | app = moduleFixture.createNestApplication() 67 | await app.init() 68 | }) 69 | 70 | afterAll(async () => { 71 | await app.close() 72 | }) 73 | 74 | describe('GET /categorias', () => { 75 | it('should return a page of categorias', async () => { 76 | // Configurar el mock para devolver un resultado específico 77 | mockCategoriasService.findAll.mockResolvedValue([myCategoria]) 78 | 79 | const { body } = await request(app.getHttpServer()) 80 | .get(myEndpoint) 81 | .expect(200) 82 | expect(() => { 83 | expect(body).toEqual([myCategoria]) 84 | expect(mockCategoriasService.findAll).toHaveBeenCalled() 85 | }) 86 | }) 87 | 88 | it('should return a page of categorias with query', async () => { 89 | // Configurar el mock para devolver un resultado específico 90 | mockCategoriasService.findAll.mockResolvedValue([myCategoria]) 91 | 92 | const { body } = await request(app.getHttpServer()) 93 | .get(`${myEndpoint}?limit=10&page=1`) 94 | .expect(200) 95 | expect(() => { 96 | expect(body).toEqual([myCategoria]) 97 | expect(mockCategoriasService.findAll).toHaveBeenCalled() 98 | }) 99 | }) 100 | }) 101 | 102 | describe('GET /categorias/:id', () => { 103 | it('should return a single categoria', async () => { 104 | mockCategoriasService.findOne.mockResolvedValue(myCategoria) 105 | 106 | const { body } = await request(app.getHttpServer()) 107 | .get(`${myEndpoint}/${myCategoria.id}`) 108 | .expect(200) 109 | expect(() => { 110 | expect(body).toEqual(myCategoria) 111 | expect(mockCategoriasService.findOne).toHaveBeenCalled() 112 | }) 113 | }) 114 | 115 | it('should throw an error if the category does not exist', async () => { 116 | mockCategoriasService.findOne.mockRejectedValue(new NotFoundException()) 117 | 118 | await request(app.getHttpServer()) 119 | .get(`${myEndpoint}/${myCategoria.id}`) 120 | .expect(404) 121 | }) 122 | }) 123 | 124 | describe('POST /categorias', () => { 125 | it('should create a new categoria', async () => { 126 | mockCategoriasService.create.mockResolvedValue(myCategoria) 127 | 128 | const { body } = await request(app.getHttpServer()) 129 | .post(myEndpoint) 130 | .send(createCategoriaDto) 131 | .expect(201) 132 | expect(() => { 133 | expect(body).toEqual(myCategoria) 134 | expect(mockCategoriasService.create).toHaveBeenCalledWith( 135 | createCategoriaDto, 136 | ) 137 | }) 138 | }) 139 | }) 140 | 141 | describe('PUT /categorias/:id', () => { 142 | it('should update a categoria', async () => { 143 | mockCategoriasService.update.mockResolvedValue(myCategoria) 144 | 145 | const { body } = await request(app.getHttpServer()) 146 | .put(`${myEndpoint}/${myCategoria.id}`) 147 | .send(updateCategoriaDto) 148 | .expect(200) 149 | expect(() => { 150 | expect(body).toEqual(myCategoria) 151 | expect(mockCategoriasService.update).toHaveBeenCalledWith( 152 | myCategoria.id, 153 | updateCategoriaDto, 154 | ) 155 | }) 156 | }) 157 | 158 | it('should throw an error if the category does not exist', async () => { 159 | mockCategoriasService.update.mockRejectedValue(new NotFoundException()) 160 | await request(app.getHttpServer()) 161 | .put(`${myEndpoint}/${myCategoria.id}`) 162 | .send(updateCategoriaDto) 163 | .expect(404) 164 | }) 165 | }) 166 | 167 | describe('DELETE /categorias/:id', () => { 168 | it('should remove a categoria', async () => { 169 | mockCategoriasService.remove.mockResolvedValue(myCategoria) 170 | 171 | await request(app.getHttpServer()) 172 | .delete(`${myEndpoint}/${myCategoria.id}`) 173 | .expect(204) 174 | }) 175 | 176 | it('should throw an error if the category does not exist', async () => { 177 | mockCategoriasService.removeSoft.mockRejectedValue( 178 | new NotFoundException(), 179 | ) 180 | await request(app.getHttpServer()) 181 | .delete(`${myEndpoint}/${myCategoria.id}`) 182 | .expect(404) 183 | }) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/productos.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { NotFoundException } from '@nestjs/common' 3 | import { UpdateCategoriaDto } from '../categorias/dto/update-categoria.dto' 4 | import { ProductosController } from './productos.controller' 5 | import { ProductosService } from './productos.service' 6 | import { ResponseProductoDto } from './dto/response-producto.dto' 7 | import { CreateProductoDto } from './dto/create-producto.dto' 8 | import { UpdateProductoDto } from './dto/update-producto.dto' 9 | import { Request } from 'express' 10 | import { Paginated } from 'nestjs-paginate' 11 | import { CacheModule } from '@nestjs/cache-manager' 12 | 13 | describe('ProductoController', () => { 14 | let controller: ProductosController 15 | let service: ProductosService 16 | 17 | const productosServiceMock = { 18 | findAll: jest.fn(), 19 | findOne: jest.fn(), 20 | create: jest.fn(), 21 | update: jest.fn(), 22 | removeSoft: jest.fn(), 23 | updateImage: jest.fn(), 24 | } 25 | 26 | // Creamos el mock del servicio con los métodos que vamos a utilizar en el controlador 27 | beforeEach(async () => { 28 | // Creamos un módulo de prueba de NestJS que nos permitirá crear una instancia de nuestro controlador. 29 | const module: TestingModule = await Test.createTestingModule({ 30 | imports: [CacheModule.register()], // importamos el módulo de caché, lo necesita el controlador (interceptores y anotaciones) 31 | controllers: [ProductosController], 32 | providers: [ 33 | { provide: ProductosService, useValue: productosServiceMock }, 34 | ], 35 | }).compile() 36 | 37 | controller = module.get(ProductosController) 38 | service = module.get(ProductosService) 39 | }) 40 | 41 | it('should be defined', () => { 42 | expect(controller).toBeDefined() 43 | }) 44 | 45 | describe('findAll', () => { 46 | it('should get all Productos', async () => { 47 | const paginateOptions = { 48 | page: 1, 49 | limit: 10, 50 | path: 'productos', 51 | } 52 | 53 | const testProductos = { 54 | data: [], 55 | meta: { 56 | itemsPerPage: 10, 57 | totalItems: 1, 58 | currentPage: 1, 59 | totalPages: 1, 60 | }, 61 | links: { 62 | current: 'productos?page=1&limit=10&sortBy=nombre:ASC', 63 | }, 64 | } as Paginated 65 | 66 | jest.spyOn(service, 'findAll').mockResolvedValue(testProductos) 67 | const result: any = await controller.findAll(paginateOptions) 68 | 69 | // console.log(result) 70 | expect(result.meta.itemsPerPage).toEqual(paginateOptions.limit) 71 | // Expect the result to have the correct currentPage 72 | expect(result.meta.currentPage).toEqual(paginateOptions.page) 73 | // Expect the result to have the correct totalPages 74 | expect(result.meta.totalPages).toEqual(1) // You may need to adjust this value based on your test case 75 | // Expect the result to have the correct current link 76 | expect(result.links.current).toEqual( 77 | `productos?page=${paginateOptions.page}&limit=${paginateOptions.limit}&sortBy=nombre:ASC`, 78 | ) 79 | expect(service.findAll).toHaveBeenCalled() 80 | }) 81 | }) 82 | 83 | describe('findOne', () => { 84 | it('should get one producto', async () => { 85 | const id = 1 86 | const mockResult: ResponseProductoDto = new ResponseProductoDto() 87 | 88 | jest.spyOn(service, 'findOne').mockResolvedValue(mockResult) 89 | await controller.findOne(id) 90 | expect(service.findOne).toHaveBeenCalledWith(id) 91 | expect(mockResult).toBeInstanceOf(ResponseProductoDto) 92 | }) 93 | 94 | it('should throw NotFoundException if producto does not exist', async () => { 95 | const id = 1 96 | jest.spyOn(service, 'findOne').mockRejectedValue(new NotFoundException()) 97 | await expect(controller.findOne(id)).rejects.toThrow(NotFoundException) 98 | }) 99 | }) 100 | 101 | describe('create', () => { 102 | it('should create a producto', async () => { 103 | const dto: CreateProductoDto = { 104 | marca: 'test', 105 | modelo: 'test', 106 | descripcion: 'test', 107 | precio: 1, 108 | stock: 1, 109 | imagen: 'test', 110 | categoria: 'test', 111 | } 112 | const mockResult: ResponseProductoDto = new ResponseProductoDto() 113 | jest.spyOn(service, 'create').mockResolvedValue(mockResult) 114 | await controller.create(dto) 115 | expect(service.create).toHaveBeenCalledWith(dto) 116 | expect(mockResult).toBeInstanceOf(ResponseProductoDto) 117 | }) 118 | }) 119 | 120 | describe('update', () => { 121 | it('should update a producto', async () => { 122 | const id = 1 123 | const dto: UpdateProductoDto = { 124 | marca: 'test', 125 | modelo: 'test', 126 | isDeleted: true, 127 | } 128 | const mockResult: ResponseProductoDto = new ResponseProductoDto() 129 | jest.spyOn(service, 'update').mockResolvedValue(mockResult) 130 | await controller.update(id, dto) 131 | expect(service.update).toHaveBeenCalledWith(id, dto) 132 | expect(mockResult).toBeInstanceOf(ResponseProductoDto) 133 | }) 134 | 135 | it('should throw NotFoundException if producto does not exist', async () => { 136 | const id = 1 137 | const dto: UpdateCategoriaDto = {} 138 | jest.spyOn(service, 'update').mockRejectedValue(new NotFoundException()) 139 | await expect(controller.update(id, dto)).rejects.toThrow( 140 | NotFoundException, 141 | ) 142 | }) 143 | }) 144 | 145 | describe('remove', () => { 146 | it('should remove a producto', async () => { 147 | const id = 1 148 | const mockResult: ResponseProductoDto = new ResponseProductoDto() 149 | jest.spyOn(service, 'removeSoft').mockResolvedValue(mockResult) 150 | await controller.remove(id) 151 | expect(service.removeSoft).toHaveBeenCalledWith(id) 152 | }) 153 | 154 | it('should throw NotFoundException if producto does not exist', async () => { 155 | const id = 1 156 | jest 157 | .spyOn(service, 'removeSoft') 158 | .mockRejectedValue(new NotFoundException()) 159 | await expect(controller.remove(id)).rejects.toThrow(NotFoundException) 160 | }) 161 | }) 162 | 163 | describe('updateImage', () => { 164 | it('should update a producto image', async () => { 165 | const mockId = 1 166 | const mockFile = {} as Express.Multer.File 167 | const mockReq = {} as Request 168 | const mockResult: ResponseProductoDto = new ResponseProductoDto() 169 | 170 | jest.spyOn(service, 'updateImage').mockResolvedValue(mockResult) 171 | 172 | await controller.updateImage(mockId, mockFile, mockReq) 173 | expect(service.updateImage).toHaveBeenCalledWith( 174 | mockId, 175 | mockFile, 176 | mockReq, 177 | true, 178 | ) 179 | expect(mockResult).toBeInstanceOf(ResponseProductoDto) 180 | }) 181 | }) 182 | }) 183 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/categorias.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Inject, 4 | Injectable, 5 | Logger, 6 | NotFoundException, 7 | } from '@nestjs/common' 8 | import { CreateCategoriaDto } from './dto/create-categoria.dto' 9 | import { UpdateCategoriaDto } from './dto/update-categoria.dto' 10 | import { InjectRepository } from '@nestjs/typeorm' 11 | import { CategoriaEntity } from './entities/categoria.entity' 12 | import { Repository } from 'typeorm' 13 | import { CategoriasMapper } from './mappers/categorias.mapper' 14 | import { v4 as uuidv4 } from 'uuid' 15 | import { Cache } from 'cache-manager' 16 | import { CACHE_MANAGER } from '@nestjs/cache-manager' 17 | import { 18 | FilterOperator, 19 | FilterSuffix, 20 | paginate, 21 | PaginateQuery, 22 | } from 'nestjs-paginate' 23 | import { hash } from 'typeorm/util/StringUtils' 24 | 25 | @Injectable() 26 | export class CategoriasService { 27 | private readonly logger = new Logger(CategoriasService.name) 28 | 29 | constructor( 30 | @InjectRepository(CategoriaEntity) 31 | private readonly categoriaRepository: Repository, 32 | private readonly categoriasMapper: CategoriasMapper, 33 | @Inject(CACHE_MANAGER) private cacheManager: Cache, 34 | ) {} 35 | 36 | async findAll(query: PaginateQuery) { 37 | this.logger.log('Find all categorias') 38 | // cache 39 | const cache = await this.cacheManager.get( 40 | `all_categories_page_${hash(JSON.stringify(query))}`, 41 | ) 42 | if (cache) { 43 | this.logger.log('Cache hit') 44 | return cache 45 | } 46 | const res = await paginate(query, this.categoriaRepository, { 47 | sortableColumns: ['nombre'], 48 | defaultSortBy: [['nombre', 'ASC']], 49 | searchableColumns: ['nombre'], 50 | filterableColumns: { 51 | nombre: [FilterOperator.EQ, FilterSuffix.NOT], 52 | isDeleted: [FilterOperator.EQ, FilterSuffix.NOT], 53 | }, 54 | // select: ['id', 'nombre', 'isDeleted', 'createdAt', 'updatedAt'], 55 | }) 56 | //console.log(res) 57 | await this.cacheManager.set( 58 | `all_categories_page_${hash(JSON.stringify(query))}`, 59 | res, 60 | 60, 61 | ) 62 | return res 63 | } 64 | 65 | async findOne(id: string): Promise { 66 | this.logger.log(`Find one categoria by id:${id}`) 67 | // cache 68 | const cache: CategoriaEntity = await this.cacheManager.get(`category_${id}`) 69 | if (cache) { 70 | this.logger.log('Cache hit') 71 | return cache 72 | } 73 | const categoriaToFound = await this.categoriaRepository.findOneBy({ id }) 74 | if (!categoriaToFound) { 75 | this.logger.log(`Categoria with id:${id} not found`) 76 | throw new NotFoundException(`Categoria con id ${id} no encontrada`) 77 | } 78 | // Guardamos en cache 79 | await this.cacheManager.set(`category_${id}`, categoriaToFound, 60) 80 | return categoriaToFound 81 | } 82 | 83 | async create( 84 | createCategoriaDto: CreateCategoriaDto, 85 | ): Promise { 86 | this.logger.log(`Create categoria ${createCategoriaDto}`) 87 | // Añadimos un id único a la categoría, porque no lo hemos hecho en el mapper 88 | const categoriaToCreate = this.categoriasMapper.toEntity(createCategoriaDto) 89 | // Existe otra categoria? 90 | const categoria = await this.exists(categoriaToCreate.nombre) 91 | 92 | if (categoria) { 93 | this.logger.log(`Categoria with name:${categoria.nombre} already exists`) 94 | throw new BadRequestException( 95 | `La categoria con nombre ${categoria.nombre} ya existe`, 96 | ) 97 | } 98 | 99 | // Añadimos los metadatos de uuid, createdAt y updatedAt 100 | const res = await this.categoriaRepository.save({ 101 | ...categoriaToCreate, 102 | id: uuidv4(), 103 | }) 104 | // Invalidamos la cache 105 | await this.invalidateCacheKey('all_categories') 106 | return res 107 | } 108 | 109 | async update( 110 | id: string, 111 | updateCategoriaDto: UpdateCategoriaDto, 112 | ): Promise { 113 | this.logger.log(`Update categoria by id:${id} - ${updateCategoriaDto}`) 114 | const categoryToUpdated = await this.findOne(id) 115 | 116 | // Existe otra categoria y si existe soy yo? 117 | // actualizamos el nombre? 118 | if (updateCategoriaDto.nombre) { 119 | const categoria = await this.exists(updateCategoriaDto.nombre) 120 | if (categoria && categoria.id !== categoryToUpdated.id) { 121 | this.logger.log( 122 | `Categoria with name:${categoria.nombre} already exists`, 123 | ) 124 | throw new BadRequestException( 125 | `La categoria con nombre ${categoria.nombre} ya existe`, 126 | ) 127 | } 128 | } 129 | 130 | const res = await this.categoriaRepository.save({ 131 | ...categoryToUpdated, 132 | ...updateCategoriaDto, 133 | }) 134 | // Invalidamos la cache 135 | await this.invalidateCacheKey(`category_${id}`) 136 | await this.invalidateCacheKey('all_categories') 137 | return res 138 | } 139 | 140 | async remove(id: string): Promise { 141 | this.logger.log(`Remove categoria by id:${id}`) 142 | const categoriaToRemove = await this.findOne(id) 143 | const res = await this.categoriaRepository.remove(categoriaToRemove) 144 | // Invalidamos la cache 145 | await this.invalidateCacheKey(`category_${id}`) 146 | await this.invalidateCacheKey('all_categories') 147 | return res 148 | } 149 | 150 | async removeSoft(id: string): Promise { 151 | this.logger.log(`Remove categoria soft by id:${id}`) 152 | const categoriaToRemove = await this.findOne(id) 153 | const res = await this.categoriaRepository.save({ 154 | ...categoriaToRemove, 155 | updatedAt: new Date(), 156 | isDeleted: true, 157 | }) 158 | // Invalidamos la cache 159 | await this.invalidateCacheKey(`category_${id}`) 160 | await this.invalidateCacheKey('all_categories') 161 | return res 162 | } 163 | 164 | public async exists(nombreCategoria: string): Promise { 165 | // Cache 166 | const cache: CategoriaEntity = await this.cacheManager.get( 167 | `category_name_${nombreCategoria}`, 168 | ) 169 | if (cache) { 170 | this.logger.log('Cache hit') 171 | return cache 172 | } 173 | // Comprobamos si existe la categoria 174 | // No uso el fin por la minúscula porque no es case sensitive 175 | const categoria = await this.categoriaRepository 176 | .createQueryBuilder() 177 | .where('LOWER(nombre) = LOWER(:nombre)', { 178 | nombre: nombreCategoria.toLowerCase(), 179 | }) 180 | .getOne() 181 | // Guardamos en caché 182 | await this.cacheManager.set( 183 | `category_name_${nombreCategoria}`, 184 | categoria, 185 | 60, 186 | ) 187 | } 188 | 189 | async invalidateCacheKey(keyPattern: string): Promise { 190 | const cacheKeys = await this.cacheManager.store.keys() 191 | const keysToDelete = cacheKeys.filter((key) => key.startsWith(keyPattern)) 192 | const promises = keysToDelete.map((key) => this.cacheManager.del(key)) 193 | await Promise.all(promises) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/pedidos/pedidos.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | Logger, 5 | NotFoundException, 6 | } from '@nestjs/common' 7 | import { CreatePedidoDto } from './dto/create-pedido.dto' 8 | import { UpdatePedidoDto } from './dto/update-pedido.dto' 9 | import { InjectModel } from '@nestjs/mongoose' 10 | import { Pedido, PedidoDocument } from './schemas/pedido.schema' 11 | import { PaginateModel } from 'mongoose' 12 | import { InjectRepository } from '@nestjs/typeorm' 13 | import { ProductoEntity } from '../productos/entities/producto.entity' 14 | import { Repository } from 'typeorm' 15 | import { PedidosMapper } from './mappers/pedidos.mapper' 16 | import { Usuario } from '../users/entities/user.entity' // has necesitado importar el esquema en el createFactory del esquema 17 | 18 | export const PedidosOrderByValues: string[] = ['_id', 'idUsuario'] // Lo usamos en los pipes 19 | export const PedidosOrderValues: string[] = ['asc', 'desc'] // Lo usamos en los pipes 20 | 21 | @Injectable() 22 | export class PedidosService { 23 | private logger = new Logger(PedidosService.name) 24 | 25 | // Inyectamos los repositorios!! 26 | constructor( 27 | @InjectModel(Pedido.name) 28 | private pedidosRepository: PaginateModel, 29 | @InjectRepository(ProductoEntity) 30 | private readonly productosRepository: Repository, 31 | @InjectRepository(Usuario) 32 | private readonly usuariosRepository: Repository, 33 | private readonly pedidosMapper: PedidosMapper, 34 | ) {} 35 | 36 | async findAll(page: number, limit: number, orderBy: string, order: string) { 37 | this.logger.log( 38 | `Buscando todos los pedidos con paginación y filtros: ${JSON.stringify({ 39 | page, 40 | limit, 41 | orderBy, 42 | order, 43 | })}`, 44 | ) 45 | // Aquí iría la query de ordenación y paginación 46 | const options = { 47 | page, 48 | limit, 49 | sort: { 50 | [orderBy]: order, 51 | }, 52 | collection: 'es_ES', // para que use la configuración de idioma de España 53 | } 54 | 55 | return await this.pedidosRepository.paginate({}, options) 56 | } 57 | 58 | async findOne(id: string) { 59 | this.logger.log(`Buscando pedido con id ${id}`) 60 | const pedidoToFind = await this.pedidosRepository.findById(id).exec() 61 | if (!pedidoToFind) { 62 | throw new NotFoundException(`Pedido con id ${id} no encontrado`) 63 | } 64 | return pedidoToFind 65 | } 66 | 67 | async findByIdUsuario(idUsuario: number) { 68 | this.logger.log(`Buscando pedidos por usuario ${idUsuario}`) 69 | return await this.pedidosRepository.find({ idUsuario }).exec() 70 | } 71 | 72 | async create(createPedidoDto: CreatePedidoDto) { 73 | this.logger.log(`Creando pedido ${JSON.stringify(createPedidoDto)}`) 74 | console.log(`Guardando pedido: ${createPedidoDto}`) 75 | 76 | const pedidoToBeSaved = this.pedidosMapper.toEntity(createPedidoDto) 77 | 78 | await this.checkPedido(pedidoToBeSaved) 79 | 80 | const pedidoToSave = await this.reserveStockPedidos(pedidoToBeSaved) 81 | 82 | pedidoToSave.createdAt = new Date() 83 | pedidoToSave.updatedAt = new Date() 84 | 85 | return await this.pedidosRepository.create(pedidoToSave) 86 | } 87 | 88 | async update(id: string, updatePedidoDto: UpdatePedidoDto) { 89 | this.logger.log( 90 | `Actualizando pedido con id ${id} y ${JSON.stringify(updatePedidoDto)}`, 91 | ) 92 | 93 | const pedidoToUpdate = await this.pedidosRepository.findById(id).exec() 94 | if (!pedidoToUpdate) { 95 | throw new NotFoundException(`Pedido con id ${id} no encontrado`) 96 | } 97 | 98 | const pedidoToBeSaved = this.pedidosMapper.toEntity(updatePedidoDto) 99 | 100 | await this.returnStockPedidos(pedidoToBeSaved) 101 | 102 | await this.checkPedido(pedidoToBeSaved) 103 | const pedidoToSave = await this.reserveStockPedidos(pedidoToBeSaved) 104 | 105 | pedidoToSave.updatedAt = new Date() 106 | 107 | return await this.pedidosRepository 108 | .findByIdAndUpdate(id, pedidoToSave, { new: true }) 109 | .exec() 110 | } 111 | 112 | async remove(id: string) { 113 | this.logger.log(`Eliminando pedido con id ${id}`) 114 | 115 | const pedidoToDelete = await this.pedidosRepository.findById(id).exec() 116 | if (!pedidoToDelete) { 117 | throw new NotFoundException(`Pedido con id ${id} no encontrado`) 118 | } 119 | await this.returnStockPedidos(pedidoToDelete) 120 | await this.pedidosRepository.findByIdAndDelete(id).exec() 121 | } 122 | 123 | async userExists(idUsuario: number): Promise { 124 | this.logger.log(`Comprobando si existe el usuario ${idUsuario}`) 125 | const usuario = await this.usuariosRepository.findOneBy({ id: idUsuario }) 126 | return !!usuario 127 | } 128 | 129 | async getPedidosByUser(idUsuario: number): Promise { 130 | this.logger.log(`Buscando pedidos por usuario ${idUsuario}`) 131 | return await this.pedidosRepository.find({ idUsuario }).exec() 132 | } 133 | 134 | private async checkPedido(pedido: Pedido): Promise { 135 | this.logger.log(`Comprobando pedido ${JSON.stringify(pedido)}`) 136 | if (!pedido.lineasPedido || pedido.lineasPedido.length === 0) { 137 | throw new BadRequestException( 138 | 'No se han agregado lineas de pedido al pedido actual', 139 | ) 140 | } 141 | 142 | for (const lineaPedido of pedido.lineasPedido) { 143 | const producto = await this.productosRepository.findOneBy({ 144 | id: lineaPedido.idProducto, 145 | }) 146 | if (!producto) { 147 | throw new BadRequestException( 148 | 'El producto con id ${lineaPedido.idProducto} no existe', 149 | ) 150 | } 151 | if (producto.stock < lineaPedido.cantidad && lineaPedido.cantidad > 0) { 152 | throw new BadRequestException( 153 | `La cantidad solicitada no es válida o no hay suficiente stock del producto ${producto.id}`, 154 | ) 155 | } 156 | if (producto.precio !== lineaPedido.precioProducto) { 157 | throw new BadRequestException( 158 | `El precio del producto ${producto.id} del pedido no coincide con el precio actual del producto`, 159 | ) 160 | } 161 | } 162 | } 163 | 164 | private async reserveStockPedidos(pedido: Pedido): Promise { 165 | this.logger.log(`Reservando stock del pedido: ${pedido}`) 166 | 167 | if (!pedido.lineasPedido || pedido.lineasPedido.length === 0) { 168 | throw new BadRequestException(`No se han agregado lineas de pedido`) 169 | } 170 | 171 | for (const lineaPedido of pedido.lineasPedido) { 172 | const producto = await this.productosRepository.findOneBy({ 173 | id: lineaPedido.idProducto, 174 | }) 175 | producto.stock -= lineaPedido.cantidad 176 | await this.productosRepository.save(producto) 177 | lineaPedido.total = lineaPedido.cantidad * lineaPedido.precioProducto 178 | } 179 | 180 | pedido.total = pedido.lineasPedido.reduce( 181 | (sum, lineaPedido) => 182 | sum + lineaPedido.cantidad * lineaPedido.precioProducto, 183 | 0, 184 | ) 185 | pedido.totalItems = pedido.lineasPedido.reduce( 186 | (sum, lineaPedido) => sum + lineaPedido.cantidad, 187 | 0, 188 | ) 189 | 190 | return pedido 191 | } 192 | 193 | private async returnStockPedidos(pedido: Pedido): Promise { 194 | this.logger.log(`Retornando stock del pedido: ${pedido}`) 195 | if (pedido.lineasPedido) { 196 | for (const lineaPedido of pedido.lineasPedido) { 197 | const producto = await this.productosRepository.findOneBy({ 198 | id: lineaPedido.idProducto, 199 | }) 200 | producto.stock += lineaPedido.cantidad 201 | await this.productosRepository.save(producto) 202 | } 203 | } 204 | return pedido 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tienda-api-nest-js/test/rest/productos/productos.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, NotFoundException } from '@nestjs/common' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | import * as request from 'supertest' 4 | import { CreateProductoDto } from '../../../src/rest/productos/dto/create-producto.dto' 5 | import { UpdateProductoDto } from '../../../src/rest/productos/dto/update-producto.dto' 6 | import { ProductosController } from '../../../src/rest/productos/productos.controller' 7 | import { ProductosService } from '../../../src/rest/productos/productos.service' 8 | import { ResponseProductoDto } from '../../../src/rest/productos/dto/response-producto.dto' 9 | import { CacheModule } from '@nestjs/cache-manager' 10 | import { JwtAuthGuard } from '../../../src/rest/auth/guards/jwt-auth.guard' 11 | import { RolesAuthGuard } from '../../../src/rest/auth/guards/roles-auth.guard' 12 | 13 | // https://ualmtorres.github.io/SeminarioTesting/#truetests-end-to-end 14 | // https://blog.logrocket.com/end-end-testing-nestjs-typeorm/ <--- MUY BUENO 15 | describe('ProductosController (e2e)', () => { 16 | let app: INestApplication 17 | const myEndpoint = `/productos` 18 | 19 | const myProductoResponse: ResponseProductoDto = { 20 | id: 1, 21 | marca: 'marca', 22 | modelo: 'modelo', 23 | descripcion: 'descripcion', 24 | precio: 100, 25 | stock: 10, 26 | imagen: 'imagen', 27 | uuid: 'uuid', 28 | createdAt: new Date(), 29 | updatedAt: new Date(), 30 | isDeleted: false, 31 | categoria: 'categoria-test', 32 | } 33 | 34 | const createProductoDto: CreateProductoDto = { 35 | marca: 'marca', 36 | modelo: 'modelo', 37 | descripcion: 'descripcion', 38 | precio: 100, 39 | stock: 10, 40 | imagen: 'imagen', 41 | categoria: 'categoria-test', 42 | } 43 | 44 | const updateProductoDto: UpdateProductoDto = { 45 | marca: 'marca', 46 | isDeleted: false, 47 | categoria: 'categoria-test', 48 | } 49 | 50 | // My mock de repository 51 | const mockProductosService = { 52 | findAll: jest.fn(), 53 | findOne: jest.fn(), 54 | create: jest.fn(), 55 | update: jest.fn(), 56 | remove: jest.fn(), 57 | removeSoft: jest.fn(), 58 | updateImage: jest.fn(), 59 | exists: jest.fn(), 60 | } 61 | 62 | beforeEach(async () => { 63 | // Cargamos solo el controlador y el servicio que vamos a probar, no el módulo que arrastra con todo 64 | // No es de integración si no e2e, con mocks 65 | const moduleFixture: TestingModule = await Test.createTestingModule({ 66 | imports: [CacheModule.register()], // importamos el módulo de caché, lo necesita el controlador (interceptores y anotaciones) 67 | controllers: [ProductosController], 68 | providers: [ 69 | ProductosService, 70 | { provide: ProductosService, useValue: mockProductosService }, 71 | ], 72 | }) 73 | // Le decimos a Nest que inyecte nuestro mock de servicio en lugar del servicio real. 74 | // Pero ya se lo hemos inyectado arriba 75 | // .overrideProvider(ProductosService) 76 | // .useValue(mockProductosService) 77 | .overrideGuard(JwtAuthGuard) 78 | .useValue({ canActivate: () => true }) // Esto permite que todas las solicitudes pasen el JwtAuthGuard 79 | .overrideGuard(RolesAuthGuard) 80 | .useValue({ canActivate: () => true }) 81 | .compile() 82 | 83 | app = moduleFixture.createNestApplication() 84 | await app.init() 85 | }) 86 | 87 | afterAll(async () => { 88 | await app.close() 89 | }) 90 | 91 | describe('GET /productos', () => { 92 | it('should return a page of productos', async () => { 93 | // Configurar el mock para devolver un resultado específico 94 | mockProductosService.findAll.mockResolvedValue([myProductoResponse]) 95 | 96 | const { body } = await request(app.getHttpServer()) 97 | .get(myEndpoint) 98 | .expect(200) 99 | expect(() => { 100 | expect(body).toEqual([myProductoResponse]) 101 | expect(mockProductosService.findAll).toHaveBeenCalled() 102 | }) 103 | }) 104 | 105 | it('should return a page of productos with query', async () => { 106 | // Configurar el mock para devolver un resultado específico 107 | mockProductosService.findAll.mockResolvedValue([myProductoResponse]) 108 | 109 | const { body } = await request(app.getHttpServer()) 110 | .get(`${myEndpoint}?page=1&limit=10`) 111 | .expect(200) 112 | expect(() => { 113 | expect(body).toEqual([myProductoResponse]) 114 | expect(mockProductosService.findAll).toHaveBeenCalled() 115 | }) 116 | }) 117 | }) 118 | 119 | describe('GET /productos/:id', () => { 120 | it('should return a single producto', async () => { 121 | mockProductosService.findOne.mockResolvedValue(myProductoResponse) 122 | 123 | const { body } = await request(app.getHttpServer()) 124 | .get(`${myEndpoint}/${myProductoResponse.id}`) 125 | .expect(200) 126 | expect(() => { 127 | expect(body).toEqual(myProductoResponse) 128 | expect(mockProductosService.findOne).toHaveBeenCalled() 129 | }) 130 | }) 131 | 132 | it('should throw an error if the producto does not exist', async () => { 133 | mockProductosService.findOne.mockRejectedValue(new NotFoundException()) 134 | 135 | await request(app.getHttpServer()) 136 | .get(`${myEndpoint}/${myProductoResponse.id}`) 137 | .expect(404) 138 | }) 139 | }) 140 | 141 | describe('POST /productos', () => { 142 | it('should create a new producto', async () => { 143 | mockProductosService.create.mockResolvedValue(myProductoResponse) 144 | 145 | const { body } = await request(app.getHttpServer()) 146 | .post(myEndpoint) 147 | .send(createProductoDto) 148 | .expect(201) 149 | expect(() => { 150 | expect(body).toEqual(myProductoResponse) 151 | expect(mockProductosService.create).toHaveBeenCalledWith( 152 | createProductoDto, 153 | ) 154 | }) 155 | }) 156 | }) 157 | 158 | describe('PUT /prouctos/:id', () => { 159 | it('should update a producto', async () => { 160 | mockProductosService.update.mockResolvedValue(myProductoResponse) 161 | 162 | const { body } = await request(app.getHttpServer()) 163 | .put(`${myEndpoint}/${myProductoResponse.id}`) 164 | .send(updateProductoDto) 165 | .expect(200) 166 | expect(() => { 167 | expect(body).toEqual(myProductoResponse) 168 | expect(mockProductosService.update).toHaveBeenCalledWith( 169 | myProductoResponse.id, 170 | updateProductoDto, 171 | ) 172 | }) 173 | }) 174 | 175 | it('should throw an error if the producto does not exist', async () => { 176 | mockProductosService.update.mockRejectedValue(new NotFoundException()) 177 | await request(app.getHttpServer()) 178 | .put(`${myEndpoint}/${myProductoResponse.id}`) 179 | .send(mockProductosService) 180 | .expect(404) 181 | }) 182 | }) 183 | 184 | describe('DELETE /productos/:id', () => { 185 | it('should remove a producto', async () => { 186 | mockProductosService.remove.mockResolvedValue(myProductoResponse) 187 | 188 | await request(app.getHttpServer()) 189 | .delete(`${myEndpoint}/${myProductoResponse.id}`) 190 | .expect(204) 191 | }) 192 | 193 | it('should throw an error if the producto does not exist', async () => { 194 | mockProductosService.removeSoft.mockRejectedValue(new NotFoundException()) 195 | await request(app.getHttpServer()) 196 | .delete(`${myEndpoint}/${myProductoResponse.id}`) 197 | .expect(404) 198 | }) 199 | }) 200 | 201 | describe('PATCH /productos/imagen/:id', () => { 202 | it('should update the product image', async () => { 203 | const file = Buffer.from('file') 204 | 205 | mockProductosService.exists.mockResolvedValue(true) 206 | 207 | mockProductosService.updateImage.mockResolvedValue(myProductoResponse) 208 | 209 | await request(app.getHttpServer()) 210 | .patch(`${myEndpoint}/imagen/${myProductoResponse.id}`) 211 | .attach('file', file, 'image.jpg') 212 | .set('Content-Type', 'multipart/form-data') 213 | .expect(200) 214 | 215 | // expect(mockProductosService.updateImage).toHaveBeenCalled() 216 | }) 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ForbiddenException, 4 | Injectable, 5 | Logger, 6 | NotFoundException, 7 | } from '@nestjs/common' 8 | import { InjectRepository } from '@nestjs/typeorm' 9 | import { Repository } from 'typeorm' 10 | import { Usuario } from './entities/user.entity' 11 | import { UsuariosMapper } from './mappers/usuarios.mapper' 12 | import { CreateUserDto } from './dto/create-user.dto' 13 | import { Role, UserRole } from './entities/user-role.entity' 14 | import { BcryptService } from './bcrypt.service' 15 | import { UpdateUserDto } from './dto/update-user.dto' 16 | import { PedidosService } from '../pedidos/pedidos.service' 17 | import { CreatePedidoDto } from '../pedidos/dto/create-pedido.dto' 18 | import { UpdatePedidoDto } from '../pedidos/dto/update-pedido.dto' 19 | 20 | @Injectable() 21 | export class UsersService { 22 | private readonly logger = new Logger(UsersService.name) 23 | 24 | constructor( 25 | @InjectRepository(Usuario) 26 | private readonly usuariosRepository: Repository, 27 | @InjectRepository(UserRole) 28 | private readonly userRoleRepository: Repository, 29 | private readonly pedidosService: PedidosService, 30 | private readonly usuariosMapper: UsuariosMapper, 31 | private readonly bcryptService: BcryptService, 32 | ) {} 33 | 34 | async findAll() { 35 | this.logger.log('findAll') 36 | return (await this.usuariosRepository.find()).map((u) => 37 | this.usuariosMapper.toResponseDto(u), 38 | ) 39 | } 40 | 41 | async findOne(id: number) { 42 | this.logger.log(`findOne: ${id}`) 43 | return this.usuariosMapper.toResponseDto( 44 | await this.usuariosRepository.findOneBy({ id }), 45 | ) 46 | } 47 | 48 | async create(createUserDto: CreateUserDto) { 49 | this.logger.log('create') 50 | // Validamos que el username no exista y no exista email en la base de datos 51 | const existingUser = await Promise.all([ 52 | this.findByUsername(createUserDto.username), 53 | this.findByEmail(createUserDto.email), 54 | ]) 55 | if (existingUser[0]) { 56 | throw new BadRequestException('username already exists') 57 | } 58 | 59 | if (existingUser[1]) { 60 | throw new BadRequestException('email already exists') 61 | } 62 | const hashPassword = await this.bcryptService.hash(createUserDto.password) 63 | 64 | // necesito insertar el usuario en la tabla de usuarios y luego en la tabla de roles 65 | const usuario = this.usuariosMapper.toEntity(createUserDto) 66 | usuario.password = hashPassword 67 | const user = await this.usuariosRepository.save(usuario) 68 | // Si no tiene roles, le asignamos el rol de usuario y lo guardamos en la tabla de roles 69 | const roles = createUserDto.roles || [Role.USER] 70 | const userRoles = roles.map((role) => ({ usuario: user, role: Role[role] })) 71 | const savedUserRoles = await this.userRoleRepository.save(userRoles) 72 | 73 | // Devolvemos el usuario con los roles 74 | return this.usuariosMapper.toResponseDtoWithRoles(user, savedUserRoles) 75 | } 76 | 77 | // Método para indicar si el una aray de roles de tipo string estan en el enum de roles de usuario 78 | validateRoles(roles: string[]): boolean { 79 | return roles.every((role) => Role[role]) 80 | } 81 | 82 | async findByUsername(username: string) { 83 | this.logger.log(`findByUsername: ${username}`) 84 | return await this.usuariosRepository.findOneBy({ username }) 85 | } 86 | 87 | async validatePassword(password: string, hashPassword: string) { 88 | this.logger.log(`validatePassword`) 89 | return await this.bcryptService.isMatch(password, hashPassword) 90 | } 91 | 92 | async deleteById(idUser: number) { 93 | this.logger.log(`deleteUserById: ${idUser}`) 94 | const user = await this.usuariosRepository.findOneBy({ id: idUser }) 95 | if (!user) { 96 | throw new NotFoundException(`User not found with id ${idUser}`) 97 | } 98 | const existsPedidos = await this.pedidosService.userExists(user.id) 99 | // Si existen pedidos, hacemos borrado logico 100 | if (existsPedidos) { 101 | user.updatedAt = new Date() 102 | user.isDeleted = true 103 | return await this.usuariosRepository.save(user) 104 | } else { 105 | // Si no existen pedidos, hacemos borrado fisico 106 | // borramos de la tabla de roles 107 | for (const userRole of user.roles) { 108 | await this.userRoleRepository.remove(userRole) 109 | } 110 | return await this.usuariosRepository.delete({ id: user.id }) 111 | } 112 | } 113 | 114 | async update( 115 | id: number, 116 | updateUserDto: UpdateUserDto, 117 | updateRoles: boolean = false, 118 | ) { 119 | this.logger.log( 120 | `updateUserProfileById: ${id} with ${JSON.stringify(updateUserDto)}`, 121 | ) 122 | const user = await this.usuariosRepository.findOneBy({ id }) 123 | if (!user) { 124 | throw new NotFoundException(`User not found with id ${id}`) 125 | } 126 | // Si el usuario quiere cambiar el username, validamos que no exista en la base de datos y si existe no sea yo mismo 127 | if (updateUserDto.username) { 128 | const existingUser = await this.findByUsername(updateUserDto.username) 129 | if (existingUser && existingUser.id !== id) { 130 | throw new BadRequestException('username already exists') 131 | } 132 | } 133 | // Si el usuario quiere cambiar el email, validamos que no exista en la base de datos y si existe no sea yo mismo 134 | if (updateUserDto.email) { 135 | const existingUser = await this.findByEmail(updateUserDto.email) 136 | if (existingUser && existingUser.id !== id) { 137 | throw new BadRequestException('email already exists') 138 | } 139 | } 140 | // Si el usuario quiere cambiar el password, lo hasheamos 141 | if (updateUserDto.password) { 142 | updateUserDto.password = await this.bcryptService.hash( 143 | updateUserDto.password, 144 | ) 145 | } 146 | // No sobrescribes los roles actuales del usuario cuando actualizas 147 | const rolesBackup = [...user.roles] 148 | Object.assign(user, updateUserDto) 149 | 150 | if (updateRoles) { 151 | // Borramos los roles antiguos y añadimos los nuevos 152 | for (const userRole of rolesBackup) { 153 | await this.userRoleRepository.remove(userRole) 154 | } 155 | const roles = updateUserDto.roles || [Role.USER] 156 | const userRoles = roles.map((role) => ({ 157 | usuario: user, 158 | role: Role[role], 159 | })) 160 | user.roles = await this.userRoleRepository.save(userRoles) 161 | } else { 162 | // Restauramos los roles originales porque no queríamos actualizarlos 163 | user.roles = rolesBackup 164 | } 165 | 166 | const updatedUser = await this.usuariosRepository.save(user) 167 | 168 | // Devolver los datos mappeados 169 | return this.usuariosMapper.toResponseDto(updatedUser) 170 | } 171 | 172 | async getPedidos(id: number) { 173 | return await this.pedidosService.getPedidosByUser(id) 174 | } 175 | 176 | async getPedido(idUser: number, idPedido: string) { 177 | const pedido = await this.pedidosService.findOne(idPedido) 178 | console.log(pedido.idUsuario) 179 | console.log(idUser) 180 | if (pedido.idUsuario != idUser) { 181 | throw new ForbiddenException( 182 | 'Do not have permission to access this resource', 183 | ) 184 | } 185 | return pedido 186 | } 187 | 188 | async createPedido(createPedidoDto: CreatePedidoDto, userId: number) { 189 | this.logger.log(`Creando pedido ${JSON.stringify(createPedidoDto)}`) 190 | if (createPedidoDto.idUsuario != userId) { 191 | throw new BadRequestException( 192 | 'Producto idUsuario must be the same as the authenticated user', 193 | ) 194 | } 195 | return await this.pedidosService.create(createPedidoDto) 196 | } 197 | 198 | async updatePedido( 199 | id: string, 200 | updatePedidoDto: UpdatePedidoDto, 201 | userId: number, 202 | ) { 203 | this.logger.log( 204 | `Actualizando pedido con id ${id} y ${JSON.stringify(updatePedidoDto)}`, 205 | ) 206 | if (updatePedidoDto.idUsuario != userId) { 207 | throw new BadRequestException( 208 | 'Producto idUsuario must be the same as the authenticated user', 209 | ) 210 | } 211 | const pedido = await this.pedidosService.findOne(id) 212 | if (pedido.idUsuario != userId) { 213 | throw new ForbiddenException( 214 | 'Do not have permission to access this resource', 215 | ) 216 | } 217 | return await this.pedidosService.update(id, updatePedidoDto) 218 | } 219 | 220 | async removePedido(idPedido: string, userId: number) { 221 | this.logger.log(`removePedido: ${idPedido}`) 222 | const pedido = await this.pedidosService.findOne(idPedido) 223 | if (pedido.idUsuario != userId) { 224 | throw new ForbiddenException( 225 | 'Do not have permission to access this resource', 226 | ) 227 | } 228 | return await this.pedidosService.remove(idPedido) 229 | } 230 | 231 | private async findByEmail(email: string) { 232 | this.logger.log(`findByEmail: ${email}`) 233 | return await this.usuariosRepository.findOneBy({ email }) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tienda-api-nest-js/database/tienda.sql: -------------------------------------------------------------------------------- 1 | -- Adminer 4.8.1 PostgreSQL 12.17 dump 2 | -- 2023-11-15 09:53:41.23703+00 3 | 4 | SELECT 'CREATE DATABASE nombre_de_la_base_de_datos' 5 | WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'tienda'); 6 | 7 | DROP TABLE IF EXISTS "productos"; 8 | DROP TABLE IF EXISTS "user_roles"; 9 | DROP TABLE IF EXISTS "usuarios"; 10 | DROP TABLE IF EXISTS "categorias"; 11 | 12 | 13 | DROP TABLE IF EXISTS "categorias"; 14 | CREATE TABLE "public"."categorias" ( 15 | "is_deleted" boolean DEFAULT false NOT NULL, 16 | "created_at" timestamp DEFAULT now() NOT NULL, 17 | "updated_at" timestamp DEFAULT now() NOT NULL, 18 | "id" uuid NOT NULL, 19 | "nombre" character varying(255) NOT NULL, 20 | CONSTRAINT "categorias_nombre_key" UNIQUE ("nombre"), 21 | CONSTRAINT "categorias_pkey" PRIMARY KEY ("id") 22 | ) WITH (oids = false); 23 | 24 | INSERT INTO "categorias" ("is_deleted", "created_at", "updated_at", "id", "nombre") VALUES 25 | ('f', '2023-11-02 11:43:24.717712', '2023-11-02 11:43:24.717712', 'd69cf3db-b77d-4181-b3cd-5ca8107fb6a9', 'DEPORTES'), 26 | ('f', '2023-11-02 11:43:24.717712', '2023-11-02 11:43:24.717712', '6dbcbf5e-8e1c-47cc-8578-7b0a33ebc154', 'COMIDA'), 27 | ('f', '2023-11-02 11:43:24.717712', '2023-11-02 11:43:24.717712', '9def16db-362b-44c4-9fc9-77117758b5b0', 'BEBIDA'), 28 | ('f', '2023-11-02 11:43:24.717712', '2023-11-02 11:43:24.717712', '8c5c06ba-49d6-46b6-85cc-8246c0f362bc', 'COMPLEMENTOS'), 29 | ('f', '2023-11-02 11:43:24.717712', '2023-11-02 11:43:24.717712', 'bb51d00d-13fb-4b09-acc9-948185636f79', 'OTROS'); 30 | 31 | DROP TABLE IF EXISTS "productos"; 32 | DROP SEQUENCE IF EXISTS productos_id_seq; 33 | CREATE SEQUENCE productos_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 6 CACHE 1; 34 | 35 | CREATE TABLE "public"."productos" ( 36 | "is_deleted" boolean DEFAULT false NOT NULL, 37 | "precio" double precision DEFAULT '0' NOT NULL, 38 | "stock" integer DEFAULT '0' NOT NULL, 39 | "created_at" timestamp DEFAULT now() NOT NULL, 40 | "id" bigint DEFAULT nextval('productos_id_seq') NOT NULL, 41 | "updated_at" timestamp DEFAULT now() NOT NULL, 42 | "categoria_id" uuid, 43 | "uuid" uuid NOT NULL, 44 | "descripcion" character varying(255), 45 | "imagen" text DEFAULT 'https://via.placeholder.com/150' NOT NULL, 46 | "marca" character varying(255) NOT NULL, 47 | "modelo" character varying(255) NOT NULL, 48 | CONSTRAINT "productos_pkey" PRIMARY KEY ("id") 49 | ) WITH (oids = false); 50 | 51 | INSERT INTO "productos" ("is_deleted", "precio", "stock", "created_at", "id", "updated_at", "categoria_id", "uuid", "descripcion", "imagen", "marca", "modelo") VALUES 52 | ('f', 10.99, 5, '2023-11-02 11:43:24.722473', 1, '2023-11-02 11:43:24.722473', 'd69cf3db-b77d-4181-b3cd-5ca8107fb6a9', '19135792-b778-441f-871e-d6e6096e0ddc', 'Descripción1', 'https://via.placeholder.com/150', 'Nike', 'Modelo1'), 53 | ('f', 19.99, 10, '2023-11-02 11:43:24.722473', 2, '2023-11-02 11:43:24.722473', '6dbcbf5e-8e1c-47cc-8578-7b0a33ebc154', '662ed342-de99-45c6-8463-446989aab9c8', 'Descripción2', 'https://via.placeholder.com/150', 'Adidas', 'Modelo2'), 54 | ('f', 15.99, 2, '2023-11-02 11:43:24.722473', 3, '2023-11-02 11:43:24.722473', 'd69cf3db-b77d-4181-b3cd-5ca8107fb6a9', 'b79182ad-91c3-46e8-90b9-268164596a72', 'Descripción3', 'https://via.placeholder.com/150', 'Nike', 'Modelo3'), 55 | ('f', 25.99, 8, '2023-11-02 11:43:24.722473', 4, '2023-11-02 11:43:24.722473', '6dbcbf5e-8e1c-47cc-8578-7b0a33ebc154', '4fa72b3f-dca2-4fd8-b803-dffacf148c10', 'Descripción4', 'https://via.placeholder.com/150', 'Nike', 'Modelo4'), 56 | ('f', 12.99, 3, '2023-11-02 11:43:24.722473', 5, '2023-11-02 11:43:24.722473', '6dbcbf5e-8e1c-47cc-8578-7b0a33ebc154', '1e2584d8-db52-45da-b2d6-4203637ea78e', 'Descripción5', 'https://via.placeholder.com/150', 'Adidas', 'Modelo5'); 57 | 58 | DROP TABLE IF EXISTS "user_roles"; 59 | DROP SEQUENCE IF EXISTS user_roles_id_seq; 60 | CREATE SEQUENCE user_roles_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 2147483647 START 6 CACHE 1; 61 | 62 | CREATE TABLE "public"."user_roles" ( 63 | "user_id" bigint, 64 | "role" character varying(50) DEFAULT 'USER' NOT NULL, 65 | "id" integer DEFAULT nextval('user_roles_id_seq') NOT NULL, 66 | CONSTRAINT "PK_8acd5cf26ebd158416f477de799" PRIMARY KEY ("id") 67 | ) WITH (oids = false); 68 | 69 | INSERT INTO "user_roles" ("user_id", "role", "id") VALUES 70 | (1, 'USER', 1), 71 | (1, 'ADMIN', 2), 72 | (2, 'USER', 3), 73 | (3, 'USER', 4), 74 | (4, 'USER', 5); 75 | 76 | DROP TABLE IF EXISTS "usuarios"; 77 | DROP SEQUENCE IF EXISTS usuarios_id_seq; 78 | CREATE SEQUENCE usuarios_id_seq INCREMENT 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 5 CACHE 1; 79 | 80 | CREATE TABLE "public"."usuarios" ( 81 | "is_deleted" boolean DEFAULT false NOT NULL, 82 | "created_at" timestamp DEFAULT now() NOT NULL, 83 | "id" bigint DEFAULT nextval('usuarios_id_seq') NOT NULL, 84 | "updated_at" timestamp DEFAULT now() NOT NULL, 85 | "apellidos" character varying(255) NOT NULL, 86 | "email" character varying(255) NOT NULL, 87 | "nombre" character varying(255) NOT NULL, 88 | "password" character varying(255) NOT NULL, 89 | "username" character varying(255) NOT NULL, 90 | CONSTRAINT "usuarios_email_key" UNIQUE ("email"), 91 | CONSTRAINT "usuarios_pkey" PRIMARY KEY ("id"), 92 | CONSTRAINT "usuarios_username_key" UNIQUE ("username") 93 | ) WITH (oids = false); 94 | 95 | INSERT INTO "usuarios" ("is_deleted", "created_at", "id", "updated_at", "apellidos", "email", "nombre", "password", "username") VALUES 96 | ('f', '2023-11-02 11:43:24.724871', 1, '2023-11-02 11:43:24.724871', 'Admin Admin', 'admin@prueba.net', 'Admin', '$2a$10$vPaqZvZkz6jhb7U7k/V/v.5vprfNdOnh4sxi/qpPRkYTzPmFlI9p2', 'admin'), 97 | ('f', '2023-11-02 11:43:24.730431', 2, '2023-11-02 11:43:24.730431', 'User User', 'user@prueba.net', 'User', '$2a$12$RUq2ScW1Kiizu5K4gKoK4OTz80.DWaruhdyfi2lZCB.KeuXTBh0S.', 'user'), 98 | ('f', '2023-11-02 11:43:24.733552', 3, '2023-11-02 11:43:24.733552', 'Test Test', 'test@prueba.net', 'Test', '$2a$10$Pd1yyq2NowcsDf4Cpf/ZXObYFkcycswqHAqBndE1wWJvYwRxlb.Pu', 'test'), 99 | ('f', '2023-11-02 11:43:24.736674', 4, '2023-11-02 11:43:24.736674', 'Otro Otro', 'otro@prueba.net', 'otro', '$2a$12$3Q4.UZbvBMBEvIwwjGEjae/zrIr6S50NusUlBcCNmBd2382eyU0bS', 'otro'); 100 | 101 | ALTER TABLE ONLY "public"."productos" ADD CONSTRAINT "FK_5aaee6054b643e7c778477193a3" FOREIGN KEY (categoria_id) REFERENCES categorias(id) NOT DEFERRABLE; 102 | 103 | ALTER TABLE ONLY "public"."user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY (user_id) REFERENCES usuarios(id) NOT DEFERRABLE; 104 | 105 | -- 2023-11-15 09:53:41.23703+00 106 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/productos.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | HttpCode, 8 | Logger, 9 | Param, 10 | ParseIntPipe, 11 | Patch, 12 | Post, 13 | Put, 14 | Req, 15 | UploadedFile, 16 | UseGuards, 17 | UseInterceptors, 18 | } from '@nestjs/common' 19 | import { ProductosService } from './productos.service' 20 | import { CreateProductoDto } from './dto/create-producto.dto' 21 | import { UpdateProductoDto } from './dto/update-producto.dto' 22 | import { FileInterceptor } from '@nestjs/platform-express' 23 | import { diskStorage } from 'multer' 24 | import { extname, parse } from 'path' 25 | import { Request } from 'express' 26 | import { ProductoExistsGuard } from './guards/producto-exists.guard' 27 | import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager' 28 | import { Paginate, Paginated, PaginateQuery } from 'nestjs-paginate' 29 | import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' 30 | import { Roles, RolesAuthGuard } from '../auth/guards/roles-auth.guard' 31 | import { 32 | ApiBadRequestResponse, 33 | ApiBearerAuth, 34 | ApiBody, 35 | ApiConsumes, 36 | ApiNotFoundResponse, 37 | ApiParam, 38 | ApiProperty, 39 | ApiQuery, 40 | ApiResponse, 41 | ApiTags, 42 | } from '@nestjs/swagger' 43 | import { ResponseProductoDto } from './dto/response-producto.dto' 44 | 45 | @Controller('productos') 46 | @UseInterceptors(CacheInterceptor) // Aplicar el interceptor aquí de cahce 47 | @ApiTags('Productos') // Aplicar el decorador en el controlador 48 | export class ProductosController { 49 | private readonly logger: Logger = new Logger(ProductosController.name) 50 | 51 | constructor(private readonly productosService: ProductosService) {} 52 | 53 | @Get() 54 | @CacheKey('all_products') 55 | @CacheTTL(30) 56 | @ApiResponse({ 57 | status: 200, 58 | description: 59 | 'Lista de productos paginada. Se puede filtrar por limite, pagina sortBy, filter y search', 60 | type: Paginated, 61 | }) 62 | @ApiQuery({ 63 | description: 'Filtro por limite por pagina', 64 | name: 'limit', 65 | required: false, 66 | type: Number, 67 | }) 68 | @ApiQuery({ 69 | description: 'Filtro por pagina', 70 | name: 'page', 71 | required: false, 72 | type: Number, 73 | }) 74 | @ApiQuery({ 75 | description: 'Filtro de ordenación: campo:ASC|DESC', 76 | name: 'sortBy', 77 | required: false, 78 | type: String, 79 | }) 80 | @ApiQuery({ 81 | description: 'Filtro de busqueda: filter.campo = $eq:valor', 82 | name: 'filter', 83 | required: false, 84 | type: String, 85 | }) 86 | @ApiQuery({ 87 | description: 'Filtro de busqueda: search = valor', 88 | name: 'search', 89 | required: false, 90 | type: String, 91 | }) 92 | async findAll(@Paginate() query: PaginateQuery) { 93 | this.logger.log('Find all productos') 94 | return await this.productosService.findAll(query) 95 | } 96 | 97 | @Get(':id') 98 | @ApiResponse({ 99 | status: 200, 100 | description: 'Producto encontrado', 101 | type: ResponseProductoDto, 102 | }) 103 | @ApiParam({ 104 | name: 'id', 105 | description: 'Identificador del producto', 106 | type: Number, 107 | }) 108 | @ApiNotFoundResponse({ 109 | description: 'Producto no encontrado', 110 | }) 111 | @ApiBadRequestResponse({ 112 | description: 'El id del producto no es válido', 113 | }) 114 | async findOne(@Param('id', ParseIntPipe) id: number) { 115 | this.logger.log(`Find one producto by id:${id}`) 116 | return await this.productosService.findOne(id) 117 | } 118 | 119 | @Post() 120 | @HttpCode(201) 121 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí 122 | @Roles('ADMIN') 123 | @ApiBearerAuth() // Indicar que se requiere autenticación con JWT en Swagger 124 | @ApiResponse({ 125 | status: 201, 126 | description: 'Producto creado', 127 | type: ResponseProductoDto, 128 | }) 129 | @ApiBody({ 130 | description: 'Datos del producto a crear', 131 | type: CreateProductoDto, 132 | }) 133 | @ApiBadRequestResponse({ 134 | description: 135 | 'El algunos de los campos no es válido según la especificación del DTO', 136 | }) 137 | @ApiBadRequestResponse({ 138 | description: 'La categoría no existe o no es válida', 139 | }) 140 | async create(@Body() createProductoDto: CreateProductoDto) { 141 | this.logger.log(`Create producto ${createProductoDto}`) 142 | return await this.productosService.create(createProductoDto) 143 | } 144 | 145 | @Put(':id') 146 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí 147 | @Roles('ADMIN') 148 | @ApiBearerAuth() // Indicar que se requiere autenticación con JWT en Swagger 149 | @ApiResponse({ 150 | status: 200, 151 | description: 'Producto actualizado', 152 | type: ResponseProductoDto, 153 | }) 154 | @ApiParam({ 155 | name: 'id', 156 | description: 'Identificador del producto', 157 | type: Number, 158 | }) 159 | @ApiBody({ 160 | description: 'Datos del producto a actualizar', 161 | type: UpdateProductoDto, 162 | }) 163 | @ApiNotFoundResponse({ 164 | description: 'Producto no encontrado', 165 | }) 166 | @ApiBadRequestResponse({ 167 | description: 168 | 'El algunos de los campos no es válido según la especificación del DTO', 169 | }) 170 | @ApiBadRequestResponse({ 171 | description: 'La categoría no existe o no es válida', 172 | }) 173 | async update( 174 | @Param('id', ParseIntPipe) id: number, 175 | @Body() updateProductoDto: UpdateProductoDto, 176 | ) { 177 | this.logger.log(`Update producto with id:${id}-${updateProductoDto}`) 178 | return await this.productosService.update(id, updateProductoDto) 179 | } 180 | 181 | @Delete(':id') 182 | @HttpCode(204) 183 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí 184 | @Roles('ADMIN') 185 | @ApiBearerAuth() // Indicar que se requiere autenticación con JWT en Swagger 186 | @ApiResponse({ 187 | status: 204, 188 | description: 'Producto eliminado', 189 | }) 190 | @ApiParam({ 191 | name: 'id', 192 | description: 'Identificador del producto', 193 | type: Number, 194 | }) 195 | @ApiNotFoundResponse({ 196 | description: 'Producto no encontrado', 197 | }) 198 | @ApiBadRequestResponse({ 199 | description: 'El id del producto no es válido', 200 | }) 201 | async remove(@Param('id', ParseIntPipe) id: number) { 202 | this.logger.log('Remove producto with id:${id}') 203 | // borrado fisico 204 | // return await this.productosService.remove(id) 205 | // borrado logico 206 | await this.productosService.removeSoft(id) 207 | } 208 | 209 | @Patch('/imagen/:id') 210 | @UseGuards(JwtAuthGuard, RolesAuthGuard) // Aplicar el guard aquí 211 | @Roles('ADMIN') 212 | @UseGuards(ProductoExistsGuard) // Aplicar el guard aquí 213 | @ApiBearerAuth() // Indicar que se requiere autenticación con JWT en Swagger 214 | @ApiResponse({ 215 | status: 200, 216 | description: 'Imagen actualizada', 217 | type: ResponseProductoDto, 218 | }) 219 | @ApiParam({ 220 | name: 'id', 221 | description: 'Identificador del producto', 222 | type: Number, 223 | }) 224 | @ApiProperty({ 225 | name: 'file', 226 | description: 'Fichero de imagen', 227 | type: 'string', 228 | format: 'binary', 229 | }) 230 | @ApiConsumes('multipart/form-data') 231 | @ApiBody({ 232 | description: 'Fichero de imagen', 233 | type: FileInterceptor('file'), 234 | }) 235 | @ApiNotFoundResponse({ 236 | description: 'Producto no encontrado', 237 | }) 238 | @ApiBadRequestResponse({ 239 | description: 'El id del producto no es válido', 240 | }) 241 | @ApiBadRequestResponse({ 242 | description: 'El fichero no es válido o de un tipo no soportado', 243 | }) 244 | @ApiBadRequestResponse({ 245 | description: 'El fichero no puede ser mayor a 1 megabyte', 246 | }) 247 | @UseInterceptors( 248 | FileInterceptor('file', { 249 | storage: diskStorage({ 250 | destination: process.env.UPLOADS_DIR || './storage-dir', 251 | filename: (req, file, cb) => { 252 | // const fileName = uuidv4() // usamos uuid para generar un nombre único para el archivo 253 | const { name } = parse(file.originalname) 254 | const fileName = `${Date.now()}_${name.replace(/\s/g, '')}` 255 | const fileExt = extname(file.originalname) // extraemos la extensión del archivo 256 | cb(null, `${fileName}${fileExt}`) // llamamos al callback con el nombre del archivo 257 | }, 258 | }), 259 | // Validación de archivos 260 | fileFilter: (req, file, cb) => { 261 | const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'] 262 | const maxFileSize = 1024 * 1024 // 1 megabyte 263 | if (!allowedMimes.includes(file.mimetype)) { 264 | // Note: You can customize this error message to be more specific 265 | cb( 266 | new BadRequestException( 267 | 'Fichero no soportado. No es del tipo imagen válido', 268 | ), 269 | false, 270 | ) 271 | } else if (file.size > maxFileSize) { 272 | cb( 273 | new BadRequestException( 274 | 'El tamaño del archivo no puede ser mayor a 1 megabyte.', 275 | ), 276 | false, 277 | ) 278 | } else { 279 | cb(null, true) 280 | } 281 | }, 282 | }), 283 | ) // 'file' es el nombre del campo en el formulario 284 | async updateImage( 285 | @Param('id', ParseIntPipe) id: number, 286 | @UploadedFile() file: Express.Multer.File, 287 | @Req() req: Request, 288 | ) { 289 | this.logger.log(`Actualizando imagen al producto con ${id}: ${file}`) 290 | 291 | return await this.productosService.updateImage(id, file, req, true) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/categorias/categorias.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { getRepositoryToken } from '@nestjs/typeorm' 3 | import { Repository } from 'typeorm' 4 | import { CategoriasService } from './categorias.service' 5 | import { CategoriaEntity } from './entities/categoria.entity' 6 | import { CreateCategoriaDto } from './dto/create-categoria.dto' 7 | import { UpdateCategoriaDto } from './dto/update-categoria.dto' 8 | import { NotFoundException } from '@nestjs/common' 9 | import { CategoriasMapper } from './mappers/categorias.mapper' 10 | import { Paginated } from 'nestjs-paginate' 11 | import { CACHE_MANAGER } from '@nestjs/cache-manager' 12 | import { Cache } from 'cache-manager' 13 | import { hash } from 'typeorm/util/StringUtils' 14 | 15 | describe('CategoriasService', () => { 16 | let service: CategoriasService 17 | let repo: Repository 18 | let mapper: CategoriasMapper 19 | let cacheManager: Cache 20 | 21 | // Creamos un mock de nuestro mapper de categorías. 22 | const categoriasMapperMock = { 23 | toEntity: jest.fn(), 24 | } 25 | 26 | const cacheManagerMock = { 27 | get: jest.fn(() => Promise.resolve()), 28 | set: jest.fn(() => Promise.resolve()), 29 | store: { 30 | keys: jest.fn(), 31 | }, 32 | } 33 | 34 | beforeEach(async () => { 35 | // Creamos un módulo de prueba de NestJS que nos permitirá crear una instancia de nuestro servicio. 36 | const module: TestingModule = await Test.createTestingModule({ 37 | // Proporcionamos una lista de dependencias que se inyectarán en nuestro servicio. 38 | providers: [ 39 | CategoriasService, 40 | { provide: CategoriasMapper, useValue: categoriasMapperMock }, 41 | { 42 | provide: getRepositoryToken(CategoriaEntity), // Obtenemos el token de la entidad CategoriaEntity para inyectarlo en el servicio. 43 | useClass: Repository, // Creamos una instancia de la clase Repository para inyectarla en el servicio 44 | }, 45 | { provide: CACHE_MANAGER, useValue: cacheManagerMock }, 46 | ], 47 | }).compile() // Compilamos el módulo de prueba. 48 | 49 | service = module.get(CategoriasService) // Obtenemos una instancia de nuestro servicio. 50 | // getRepositoryToken es una función de NestJS que se utiliza para generar un token de inyección de dependencias para un repositorio de TypeORM. 51 | repo = module.get>( 52 | getRepositoryToken(CategoriaEntity), 53 | ) // Obtenemos una instancia de nuestro repositorio de categorías. 54 | mapper = module.get(CategoriasMapper) // Obtenemos una instancia de nuestro mapper de categorías. 55 | cacheManager = module.get(CACHE_MANAGER) // Obtenemos una instancia del caché 56 | }) 57 | 58 | it('should be defined', () => { 59 | expect(service).toBeDefined() 60 | }) 61 | 62 | describe('findAll', () => { 63 | it('should return a page of categories', async () => { 64 | // Mock the cacheManager.get method to return null 65 | 66 | // Create a mock PaginateQuery object 67 | const paginateOptions = { 68 | page: 1, 69 | limit: 10, 70 | path: 'categorias', 71 | } 72 | 73 | // Mock the paginate method to return a Paginated object 74 | const testCategories = { 75 | data: [], 76 | meta: { 77 | itemsPerPage: 10, 78 | totalItems: 1, 79 | currentPage: 1, 80 | totalPages: 1, 81 | }, 82 | links: { 83 | current: 'categorias?page=1&limit=10&sortBy=nombre:ASC', 84 | }, 85 | } as Paginated 86 | 87 | jest.spyOn(cacheManager, 'get').mockResolvedValue(Promise.resolve(null)) 88 | 89 | // Mock the cacheManager.set method 90 | jest.spyOn(cacheManager, 'set').mockResolvedValue() 91 | 92 | // Debemos simular la consulta 93 | const mockQueryBuilder = { 94 | take: jest.fn().mockReturnThis(), 95 | skip: jest.fn().mockReturnThis(), 96 | addOrderBy: jest.fn().mockReturnThis(), 97 | getManyAndCount: jest.fn().mockResolvedValue([testCategories, 1]), 98 | } 99 | 100 | jest 101 | .spyOn(repo, 'createQueryBuilder') 102 | .mockReturnValue(mockQueryBuilder as any) 103 | 104 | // Call the findAll method 105 | const result: any = await service.findAll(paginateOptions) 106 | 107 | // console.log(result) 108 | expect(result.meta.itemsPerPage).toEqual(paginateOptions.limit) 109 | // Expect the result to have the correct currentPage 110 | expect(result.meta.currentPage).toEqual(paginateOptions.page) 111 | // Expect the result to have the correct totalPages 112 | expect(result.meta.totalPages).toEqual(1) // You may need to adjust this value based on your test case 113 | // Expect the result to have the correct current link 114 | expect(result.links.current).toEqual( 115 | `categorias?page=${paginateOptions.page}&limit=${paginateOptions.limit}&sortBy=nombre:ASC`, 116 | ) 117 | expect(cacheManager.get).toHaveBeenCalled() 118 | expect(cacheManager.set).toHaveBeenCalled() 119 | }) 120 | 121 | it('should return cached result', async () => { 122 | // Create a mock PaginateQuery object 123 | const paginateOptions = { 124 | page: 1, 125 | limit: 10, 126 | path: 'categorias', 127 | } 128 | 129 | // Mock the paginate method to return a Paginated object 130 | const testCategories = { 131 | data: [], 132 | meta: { 133 | itemsPerPage: 10, 134 | totalItems: 1, 135 | currentPage: 1, 136 | totalPages: 1, 137 | }, 138 | links: { 139 | current: 'categorias?page=1&limit=10&sortBy=nombre:ASC', 140 | }, 141 | } as Paginated 142 | 143 | // Mock the cacheManager.get method to return a cached result 144 | jest.spyOn(cacheManager, 'get').mockResolvedValue(testCategories) 145 | 146 | // Call the findAll method 147 | const result = await service.findAll(paginateOptions) 148 | 149 | // Expect the cacheManager.get method to be called with the correct key 150 | expect(cacheManager.get).toHaveBeenCalledWith( 151 | `all_categories_page_${hash(JSON.stringify(paginateOptions))}`, 152 | ) 153 | 154 | // Expect the result to be the cached result 155 | expect(result).toEqual(testCategories) 156 | }) 157 | }) 158 | 159 | describe('findOne', () => { 160 | it('should return a single category', async () => { 161 | const testCategory = new CategoriaEntity() 162 | jest.spyOn(cacheManager, 'get').mockResolvedValue(Promise.resolve(null)) 163 | 164 | jest.spyOn(repo, 'findOneBy').mockResolvedValue(testCategory) 165 | 166 | jest.spyOn(cacheManager, 'set').mockResolvedValue() 167 | 168 | expect(await service.findOne('1')).toEqual(testCategory) 169 | }) 170 | 171 | it('should throw an error if the category does not exist', async () => { 172 | jest.spyOn(repo, 'findOneBy').mockResolvedValue(null) 173 | await expect(service.findOne('1')).rejects.toThrow(NotFoundException) 174 | }) 175 | }) 176 | 177 | describe('create', () => { 178 | it('should successfully insert a category', async () => { 179 | const testCategory = new CategoriaEntity() 180 | testCategory.nombre = 'test' 181 | 182 | const mockQueryBuilder = { 183 | where: jest.fn().mockReturnThis(), // Añade esto 184 | getOne: jest.fn().mockResolvedValue(null), 185 | } 186 | 187 | jest 188 | .spyOn(repo, 'createQueryBuilder') 189 | .mockReturnValue(mockQueryBuilder as any) 190 | jest.spyOn(mapper, 'toEntity').mockReturnValue(testCategory) 191 | jest.spyOn(repo, 'save').mockResolvedValue(testCategory) 192 | jest.spyOn(service, 'exists').mockResolvedValue(null) // Simula la función 'exists' 193 | 194 | jest.spyOn(cacheManager.store, 'keys').mockResolvedValue([]) 195 | 196 | expect(await service.create(new CreateCategoriaDto())).toEqual( 197 | testCategory, 198 | ) 199 | expect(mapper.toEntity).toHaveBeenCalled() 200 | }) 201 | }) 202 | 203 | describe('update', () => { 204 | it('should call the update method', async () => { 205 | const testCategory = new CategoriaEntity() 206 | testCategory.nombre = 'test' 207 | 208 | const mockQueryBuilder = { 209 | where: jest.fn().mockReturnThis(), 210 | getOne: jest.fn().mockResolvedValue(testCategory), 211 | } 212 | 213 | const mockUpdateCategoriaDto = new UpdateCategoriaDto() 214 | 215 | jest 216 | .spyOn(repo, 'createQueryBuilder') 217 | .mockReturnValue(mockQueryBuilder as any) 218 | jest.spyOn(service, 'exists').mockResolvedValue(testCategory) // Simula la función 'exists' 219 | jest.spyOn(repo, 'findOneBy').mockResolvedValue(testCategory) 220 | jest.spyOn(mapper, 'toEntity').mockReturnValue(testCategory) 221 | jest.spyOn(repo, 'save').mockResolvedValue(testCategory) 222 | 223 | const result = await service.update('1', mockUpdateCategoriaDto) 224 | 225 | expect(result).toEqual(testCategory) 226 | }) 227 | }) 228 | 229 | describe('remove', () => { 230 | it('should call the delete method', async () => { 231 | const testCategory = new CategoriaEntity() 232 | jest.spyOn(repo, 'findOneBy').mockResolvedValue(testCategory) 233 | jest.spyOn(repo, 'remove').mockResolvedValue(testCategory) 234 | 235 | expect(await service.remove('1')).toEqual(testCategory) 236 | }) 237 | }) 238 | 239 | describe('removeSoft', () => { 240 | it('should call the soft delete method', async () => { 241 | const testCategory = new CategoriaEntity() 242 | jest.spyOn(repo, 'findOneBy').mockResolvedValue(testCategory) 243 | jest.spyOn(repo, 'save').mockResolvedValue(testCategory) 244 | 245 | expect(await service.removeSoft('1')).toEqual(testCategory) 246 | }) 247 | }) 248 | }) 249 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/productos.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Inject, 4 | Injectable, 5 | Logger, 6 | NotFoundException, 7 | } from '@nestjs/common' 8 | import { CreateProductoDto } from './dto/create-producto.dto' 9 | import { UpdateProductoDto } from './dto/update-producto.dto' 10 | import { InjectRepository } from '@nestjs/typeorm' 11 | import { ProductoEntity } from './entities/producto.entity' 12 | import { Repository } from 'typeorm' 13 | import { CategoriaEntity } from '../categorias/entities/categoria.entity' 14 | import { ProductosMapper } from './mappers/productos.mapper' 15 | import { ResponseProductoDto } from './dto/response-producto.dto' 16 | import { StorageService } from '../storage/storage.service' 17 | import { Request } from 'express' 18 | import { ProductsNotificationsGateway } from '../../websockets/notifications/products-notifications.gateway' 19 | import { 20 | Notificacion, 21 | NotificacionTipo, 22 | } from '../../websockets/notifications/models/notificacion.model' 23 | import { Cache } from 'cache-manager' 24 | import { CACHE_MANAGER } from '@nestjs/cache-manager' 25 | import { 26 | FilterOperator, 27 | FilterSuffix, 28 | paginate, 29 | PaginateQuery, 30 | } from 'nestjs-paginate' 31 | import { hash } from 'typeorm/util/StringUtils' 32 | 33 | @Injectable() 34 | export class ProductosService { 35 | private readonly logger: Logger = new Logger(ProductosService.name) 36 | 37 | // Inmyectamos el repositorio de la entidad ProductoEntity 38 | constructor( 39 | @InjectRepository(ProductoEntity) 40 | private readonly productoRepository: Repository, 41 | @InjectRepository(CategoriaEntity) 42 | private readonly categoriaRepository: Repository, 43 | private readonly productosMapper: ProductosMapper, 44 | private readonly storageService: StorageService, 45 | private readonly productsNotificationsGateway: ProductsNotificationsGateway, 46 | @Inject(CACHE_MANAGER) private cacheManager: Cache, 47 | ) {} 48 | 49 | //Implementar el método findAll y findOne con inner join para que devuelva el nombre de la categoría 50 | 51 | async findAll(query: PaginateQuery) { 52 | this.logger.log('Find all productos') 53 | // check cache 54 | const cache = await this.cacheManager.get( 55 | `all_products_page_${hash(JSON.stringify(query))}`, 56 | ) 57 | if (cache) { 58 | this.logger.log('Cache hit') 59 | return cache 60 | } 61 | 62 | // Creo el queryBuilder para poder hacer el leftJoinAndSelect con la categoría 63 | const queryBuilder = this.productoRepository 64 | .createQueryBuilder('producto') 65 | .leftJoinAndSelect('producto.categoria', 'categoria') 66 | 67 | const pagination = await paginate(query, queryBuilder, { 68 | sortableColumns: ['marca', 'modelo', 'descripcion', 'precio', 'stock'], 69 | defaultSortBy: [['id', 'ASC']], 70 | searchableColumns: ['marca', 'modelo', 'descripcion', 'precio', 'stock'], 71 | filterableColumns: { 72 | marca: [FilterOperator.EQ, FilterSuffix.NOT], 73 | modelo: [FilterOperator.EQ, FilterSuffix.NOT], 74 | descripcion: [FilterOperator.EQ, FilterSuffix.NOT], 75 | precio: true, 76 | stock: true, 77 | isDeleted: [FilterOperator.EQ, FilterSuffix.NOT], 78 | }, 79 | //select: ['id', 'marca', 'modelo', 'descripcion', 'precio', 'stock'], 80 | }) 81 | 82 | // console.log(pagination) 83 | 84 | // mapeamos los elementos de la pagina para devolverlos como queremos con la categoria 85 | // pero debe existir la propiodad y no se indefinido, si no es un [] 86 | const res = { 87 | data: (pagination.data ?? []).map((product) => 88 | this.productosMapper.toResponseDto(product), 89 | ), 90 | meta: pagination.meta, 91 | links: pagination.links, 92 | } 93 | 94 | // Guardamos en caché 95 | await this.cacheManager.set( 96 | `all_products_page_${hash(JSON.stringify(query))}`, 97 | res, 98 | 60, 99 | ) 100 | return res 101 | } 102 | 103 | async findOne(id: number): Promise { 104 | this.logger.log(`Find one producto by id:${id}`) 105 | // Cache 106 | const cache: ResponseProductoDto = await this.cacheManager.get( 107 | `product_${id}`, 108 | ) 109 | if (cache) { 110 | console.log('Cache hit') 111 | this.logger.log('Cache hit') 112 | return cache 113 | } 114 | // No puedo usar .findOneBy, porque quiero devolver el nombre de la categoría 115 | const productToFind = await this.productoRepository 116 | .createQueryBuilder('producto') 117 | .leftJoinAndSelect('producto.categoria', 'categoria') 118 | .where('producto.id = :id', { id }) 119 | .getOne() 120 | 121 | if (!productToFind) { 122 | throw new NotFoundException(`Producto con id ${id} no encontrado`) 123 | } 124 | 125 | const res = this.productosMapper.toResponseDto(productToFind) 126 | // Guardamos en caché 127 | await this.cacheManager.set(`product_${id}`, res, 60) 128 | return res 129 | } 130 | 131 | async create( 132 | createProductoDto: CreateProductoDto, 133 | ): Promise { 134 | this.logger.log('Create producto ${createProductoDto}') 135 | const categoria = await this.checkCategoria(createProductoDto.categoria) 136 | const productoToCreate = this.productosMapper.toEntity( 137 | createProductoDto, 138 | categoria, 139 | ) 140 | const productoCreated = await this.productoRepository.save(productoToCreate) 141 | const dto = this.productosMapper.toResponseDto(productoCreated) 142 | this.onChange(NotificacionTipo.CREATE, dto) 143 | await this.invalidateCacheKey('all_products') 144 | return dto 145 | } 146 | 147 | async update( 148 | id: number, 149 | updateProductoDto: UpdateProductoDto, 150 | ): Promise { 151 | this.logger.log(`Update producto by id:${id} - ${updateProductoDto}`) 152 | const productToUpdate = await this.findOne(id) 153 | let categoria: CategoriaEntity 154 | if (updateProductoDto.categoria) { 155 | // tiene categoria, comprobamos que exista 156 | categoria = await this.checkCategoria(updateProductoDto.categoria) 157 | } else { 158 | // no tiene categoria, dejamos la que tenía 159 | categoria = await this.checkCategoria(productToUpdate.categoria) 160 | } 161 | const productoUpdated = await this.productoRepository.save({ 162 | ...productToUpdate, 163 | ...updateProductoDto, 164 | categoria, 165 | }) 166 | const dto = this.productosMapper.toResponseDto(productoUpdated) 167 | this.onChange(NotificacionTipo.UPDATE, dto) 168 | // Invalida la caché del producto específico y 'product_all' cuando se actualiza un producto 169 | await this.invalidateCacheKey(`product_${id}`) 170 | await this.invalidateCacheKey('all_products') 171 | return dto 172 | } 173 | 174 | async remove(id: number): Promise { 175 | this.logger.log(`Remove producto by id:${id}`) 176 | const productToRemove = await this.exists(id) 177 | const productoRemoved = 178 | await this.productoRepository.remove(productToRemove) 179 | // Borramos su imagen si es distinta a la imagen por defecto 180 | if (productToRemove.imagen !== ProductoEntity.IMAGE_DEFAULT) { 181 | this.logger.log(`Borrando imagen ${productToRemove.imagen}`) 182 | this.storageService.removeFile(productToRemove.imagen) 183 | } 184 | const dto = this.productosMapper.toResponseDto(productoRemoved) 185 | this.onChange(NotificacionTipo.DELETE, dto) 186 | await this.invalidateCacheKey(`product_${id}`) 187 | await this.invalidateCacheKey('all_products') 188 | return dto 189 | } 190 | 191 | async removeSoft(id: number) { 192 | this.logger.log(`Remove producto by id:${id}`) 193 | const productToRemove = await this.exists(id) 194 | productToRemove.isDeleted = true 195 | const productoRemoved = await this.productoRepository.save(productToRemove) 196 | const dto = this.productosMapper.toResponseDto(productoRemoved) 197 | this.onChange(NotificacionTipo.DELETE, dto) 198 | await this.invalidateCacheKey(`product_${id}`) 199 | await this.invalidateCacheKey('all_products') 200 | return dto 201 | } 202 | 203 | public async checkCategoria( 204 | nombreCategoria: string, 205 | ): Promise { 206 | // Cache 207 | const cache: CategoriaEntity = await this.cacheManager.get( 208 | `category_${nombreCategoria}`, 209 | ) 210 | if (cache) { 211 | this.logger.log('Cache hit') 212 | return cache 213 | } 214 | // Comprobamos si existe la categoria 215 | // No uso el fin por la minúscula porque no es case sensitive 216 | const categoria = await this.categoriaRepository 217 | .createQueryBuilder() 218 | .where('LOWER(nombre) = LOWER(:nombre)', { 219 | nombre: nombreCategoria.toLowerCase(), 220 | }) 221 | .getOne() 222 | 223 | if (!categoria) { 224 | this.logger.log(`Categoría ${nombreCategoria} no existe`) 225 | throw new BadRequestException(`Categoría ${nombreCategoria} no existe`) 226 | } 227 | 228 | // Guardamos en caché 229 | await this.cacheManager.set(`category_${nombreCategoria}`, categoria, 60) 230 | return categoria 231 | } 232 | 233 | public async exists(id: number): Promise { 234 | // Cache 235 | const cache: ProductoEntity = await this.cacheManager.get( 236 | `product_entity_${id}`, 237 | ) 238 | if (cache) { 239 | this.logger.log('Cache hit') 240 | return cache 241 | } 242 | const product = await this.productoRepository.findOneBy({ id }) 243 | if (!product) { 244 | this.logger.log(`Producto con id ${id} no encontrado`) 245 | throw new NotFoundException(`Producto con id ${id} no encontrado`) 246 | } 247 | // Guardamos en caché 248 | await this.cacheManager.set(`product_entity_${id}`, product, 60) 249 | return product 250 | } 251 | 252 | public async updateImage( 253 | id: number, 254 | file: Express.Multer.File, 255 | req: Request, 256 | withUrl: boolean = true, 257 | ) { 258 | this.logger.log(`Update image producto by id:${id}`) 259 | const productToUpdate = await this.exists(id) 260 | 261 | // Borramos su imagen si es distinta a la imagen por defecto 262 | if (productToUpdate.imagen !== ProductoEntity.IMAGE_DEFAULT) { 263 | this.logger.log(`Borrando imagen ${productToUpdate.imagen}`) 264 | let imagePath = productToUpdate.imagen 265 | if (withUrl) { 266 | imagePath = this.storageService.getFileNameWithouUrl( 267 | productToUpdate.imagen, 268 | ) 269 | } 270 | try { 271 | this.storageService.removeFile(imagePath) 272 | } catch (error) { 273 | this.logger.error(error) // No lanzamos nada si no existe!! 274 | } 275 | } 276 | 277 | if (!file) { 278 | throw new BadRequestException('Fichero no encontrado.') 279 | } 280 | 281 | let filePath: string 282 | 283 | if (withUrl) { 284 | this.logger.log(`Generando url para ${file.filename}`) 285 | // Construimos la url del fichero, que será la url de la API + el nombre del fichero 286 | const apiVersion = process.env.API_VERSION 287 | ? `/${process.env.API_VERSION}` 288 | : '' 289 | filePath = `${req.protocol}://${req.get('host')}${apiVersion}/storage/${ 290 | file.filename 291 | }` 292 | } else { 293 | filePath = file.filename 294 | } 295 | 296 | productToUpdate.imagen = filePath 297 | const productoUpdated = await this.productoRepository.save(productToUpdate) 298 | const dto = this.productosMapper.toResponseDto(productoUpdated) 299 | this.onChange(NotificacionTipo.UPDATE, dto) 300 | await this.invalidateCacheKey(`product_${id}`) 301 | await this.invalidateCacheKey('all_products') 302 | return dto 303 | } 304 | 305 | async invalidateCacheKey(keyPattern: string): Promise { 306 | const cacheKeys = await this.cacheManager.store.keys() 307 | const keysToDelete = cacheKeys.filter((key) => key.startsWith(keyPattern)) 308 | const promises = keysToDelete.map((key) => this.cacheManager.del(key)) 309 | await Promise.all(promises) 310 | } 311 | 312 | private onChange(tipo: NotificacionTipo, data: ResponseProductoDto) { 313 | const notificacion = new Notificacion( 314 | 'PRODUCTOS', 315 | tipo, 316 | data, 317 | new Date(), 318 | ) 319 | // Lo enviamos 320 | this.productsNotificationsGateway.sendMessage(notificacion) 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /tienda-api-nest-js/src/rest/productos/productos.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { ProductosService } from './productos.service' 3 | import { getRepositoryToken } from '@nestjs/typeorm' 4 | import { ProductoEntity } from './entities/producto.entity' 5 | import { CategoriaEntity } from '../categorias/entities/categoria.entity' 6 | import { ProductosMapper } from './mappers/productos.mapper' 7 | import { Repository } from 'typeorm' 8 | import { BadRequestException, NotFoundException } from '@nestjs/common' 9 | import { ResponseProductoDto } from './dto/response-producto.dto' 10 | import { CreateProductoDto } from './dto/create-producto.dto' 11 | import { UpdateProductoDto } from './dto/update-producto.dto' 12 | import { StorageService } from '../storage/storage.service' 13 | import { ProductsNotificationsGateway } from '../../websockets/notifications/products-notifications.gateway' 14 | import { NotificationsModule } from '../../websockets/notifications/notifications.module' 15 | import { Paginated } from 'nestjs-paginate' 16 | import { Cache } from 'cache-manager' 17 | import { CACHE_MANAGER } from '@nestjs/cache-manager' 18 | import { hash } from 'typeorm/util/StringUtils' 19 | 20 | describe('ProductosService', () => { 21 | let service: ProductosService // servicio 22 | let productoRepository: Repository // repositorio 23 | let categoriaRepository: Repository // repositorio 24 | let mapper: ProductosMapper // mapper 25 | let storageService: StorageService // servicio de almacenamiento 26 | let productsNotificationsGateway: ProductsNotificationsGateway // gateway de notificaciones 27 | let cacheManager: Cache 28 | 29 | const productosMapperMock = { 30 | toEntity: jest.fn(), 31 | toResponseDto: jest.fn(), 32 | } 33 | 34 | const storageServiceMock = { 35 | removeFile: jest.fn(), 36 | getFileNameWithouUrl: jest.fn(), 37 | } 38 | 39 | const productsNotificationsGatewayMock = { 40 | sendMessage: jest.fn(), 41 | } 42 | 43 | const cacheManagerMock = { 44 | get: jest.fn(() => Promise.resolve()), 45 | set: jest.fn(() => Promise.resolve()), 46 | store: { 47 | keys: jest.fn(), 48 | }, 49 | } 50 | 51 | // Creamos un módulo de prueba de NestJS que nos permitirá crear una instancia de nuestro servicio. 52 | beforeEach(async () => { 53 | const module: TestingModule = await Test.createTestingModule({ 54 | imports: [NotificationsModule], 55 | // Proporcionamos una lista de dependencias que se inyectarán en nuestro servicio. 56 | providers: [ 57 | // Inyectamos al servicio los repositorios y el mapper 58 | ProductosService, 59 | { provide: getRepositoryToken(ProductoEntity), useClass: Repository }, 60 | { provide: getRepositoryToken(CategoriaEntity), useClass: Repository }, 61 | { provide: ProductosMapper, useValue: productosMapperMock }, 62 | { provide: StorageService, useValue: storageServiceMock }, 63 | { 64 | provide: ProductsNotificationsGateway, 65 | useValue: productsNotificationsGatewayMock, 66 | }, 67 | { provide: CACHE_MANAGER, useValue: cacheManagerMock }, 68 | ], 69 | }).compile() 70 | 71 | service = module.get(ProductosService) // Obtenemos una instancia de nuestro servicio. 72 | productoRepository = module.get(getRepositoryToken(ProductoEntity)) // Obtenemos una instancia del repositorio de productos 73 | categoriaRepository = module.get(getRepositoryToken(CategoriaEntity)) // Obtenemos una instancia del repositorio de categorías 74 | mapper = module.get(ProductosMapper) // Obtenemos una instancia del mapper 75 | storageService = module.get(StorageService) // Obtenemos una instancia del servicio de almacenamiento 76 | productsNotificationsGateway = module.get( 77 | ProductsNotificationsGateway, 78 | ) // Obtenemos una instancia del gateway de notificaciones 79 | cacheManager = module.get(CACHE_MANAGER) // Obtenemos una instancia del cache manager 80 | }) 81 | 82 | it('should be defined', () => { 83 | expect(service).toBeDefined() 84 | }) 85 | 86 | describe('findAll', () => { 87 | it('should return a page of products', async () => { 88 | // Create a mock PaginateQuery object 89 | const paginateOptions = { 90 | page: 1, 91 | limit: 10, 92 | path: 'productos', 93 | } 94 | 95 | // Mock the paginate method to return a Paginated object 96 | const testProductos = { 97 | data: [], 98 | meta: { 99 | itemsPerPage: 10, 100 | totalItems: 1, 101 | currentPage: 1, 102 | totalPages: 1, 103 | }, 104 | links: { 105 | current: 'productos?page=1&limit=10&sortBy=nombre:ASC', 106 | }, 107 | } as Paginated 108 | 109 | jest.spyOn(cacheManager, 'get').mockResolvedValue(Promise.resolve(null)) 110 | 111 | // Mock the cacheManager.set method 112 | jest.spyOn(cacheManager, 'set').mockResolvedValue() 113 | 114 | // Debemos simular la consulta 115 | const mockQueryBuilder = { 116 | leftJoinAndSelect: jest.fn().mockReturnThis(), 117 | take: jest.fn().mockReturnThis(), 118 | skip: jest.fn().mockReturnThis(), 119 | addOrderBy: jest.fn().mockReturnThis(), 120 | getManyAndCount: jest.fn().mockResolvedValue([]), 121 | } 122 | 123 | jest 124 | .spyOn(productoRepository, 'createQueryBuilder') 125 | .mockReturnValue(mockQueryBuilder as any) 126 | 127 | jest 128 | .spyOn(mapper, 'toResponseDto') 129 | .mockReturnValue(new ResponseProductoDto()) 130 | 131 | // Call the findAll method 132 | const result: any = await service.findAll(paginateOptions) 133 | 134 | // console.log(result) 135 | 136 | expect(result.meta.itemsPerPage).toEqual(paginateOptions.limit) 137 | // Expect the result to have the correct currentPage 138 | expect(result.meta.currentPage).toEqual(paginateOptions.page) 139 | // Expect the result to have the correct totalPages 140 | // expect(result.meta.totalPages).toEqual(1) // You may need to adjust this value based on your test case 141 | // Expect the result to have the correct current link 142 | expect(result.links.current).toEqual( 143 | `productos?page=${paginateOptions.page}&limit=${paginateOptions.limit}&sortBy=id:ASC`, 144 | ) 145 | expect(cacheManager.get).toHaveBeenCalled() 146 | expect(cacheManager.set).toHaveBeenCalled() 147 | }) 148 | 149 | it('should return cached result', async () => { 150 | // Create a mock PaginateQuery object 151 | const paginateOptions = { 152 | page: 1, 153 | limit: 10, 154 | path: 'productos', 155 | } 156 | 157 | // Mock the paginate method to return a Paginated object 158 | const testProductos = { 159 | data: [], 160 | meta: { 161 | itemsPerPage: 10, 162 | totalItems: 1, 163 | currentPage: 1, 164 | totalPages: 1, 165 | }, 166 | links: { 167 | current: 'productos?page=1&limit=10&sortBy=nombre:ASC', 168 | }, 169 | } as Paginated 170 | 171 | // Mock the cacheManager.get method to return a cached result 172 | jest.spyOn(cacheManager, 'get').mockResolvedValue(testProductos) 173 | 174 | // Call the findAll method 175 | const result = await service.findAll(paginateOptions) 176 | 177 | // Expect the cacheManager.get method to be called with the correct key 178 | expect(cacheManager.get).toHaveBeenCalledWith( 179 | `all_products_page_${hash(JSON.stringify(paginateOptions))}`, 180 | ) 181 | 182 | // Expect the result to be the cached result 183 | expect(result).toEqual(testProductos) 184 | }) 185 | }) 186 | 187 | describe('findOne', () => { 188 | it('should retrieve a producto by id', async () => { 189 | const result = new ProductoEntity() 190 | const resultDto = new ResponseProductoDto() 191 | const mockQueryBuilder = { 192 | leftJoinAndSelect: jest.fn().mockReturnThis(), 193 | orderBy: jest.fn().mockReturnThis(), 194 | where: jest.fn().mockReturnThis(), // Añade esto 195 | getOne: jest.fn().mockResolvedValue(result), 196 | } 197 | 198 | jest.spyOn(cacheManager, 'get').mockResolvedValue(Promise.resolve(null)) 199 | 200 | jest 201 | .spyOn(productoRepository, 'createQueryBuilder') 202 | .mockReturnValue(mockQueryBuilder as any) 203 | 204 | jest.spyOn(mapper, 'toResponseDto').mockReturnValue(resultDto) 205 | 206 | jest.spyOn(cacheManager, 'set').mockResolvedValue() 207 | 208 | expect(await service.findOne(1)).toEqual(resultDto) 209 | expect(mapper.toResponseDto).toHaveBeenCalledTimes(1) 210 | }) 211 | 212 | it('should throw an error if producto does not exist', async () => { 213 | const mockQueryBuilder = { 214 | leftJoinAndSelect: jest.fn().mockReturnThis(), 215 | where: jest.fn().mockReturnThis(), 216 | orderBy: jest.fn().mockReturnThis(), 217 | getOne: jest.fn().mockResolvedValue(null), 218 | } 219 | 220 | jest 221 | .spyOn(productoRepository, 'createQueryBuilder') 222 | .mockReturnValue(mockQueryBuilder as any) 223 | await expect(service.findOne(1)).rejects.toThrow(NotFoundException) 224 | }) 225 | }) 226 | 227 | describe('create', () => { 228 | it('should create a new producto', async () => { 229 | const createProductoDto = new CreateProductoDto() 230 | 231 | const mockCategoriaEntity = new CategoriaEntity() 232 | const mockProductoEntity = new ProductoEntity() 233 | const mockResponseProductoDto = new ResponseProductoDto() 234 | 235 | jest 236 | .spyOn(service, 'checkCategoria') 237 | .mockResolvedValue(mockCategoriaEntity) 238 | 239 | jest.spyOn(mapper, 'toEntity').mockReturnValue(mockProductoEntity) 240 | 241 | jest 242 | .spyOn(productoRepository, 'save') 243 | .mockResolvedValue(mockProductoEntity) 244 | 245 | jest 246 | .spyOn(mapper, 'toResponseDto') 247 | .mockReturnValue(mockResponseProductoDto) 248 | 249 | jest.spyOn(cacheManager.store, 'keys').mockResolvedValue([]) 250 | 251 | expect(await service.create(createProductoDto)).toEqual( 252 | mockResponseProductoDto, 253 | ) 254 | expect(mapper.toEntity).toHaveBeenCalled() 255 | expect(productoRepository.save).toHaveBeenCalled() 256 | expect(service.checkCategoria).toHaveBeenCalled() 257 | }) 258 | }) 259 | 260 | describe('update', () => { 261 | it('should update a producto', async () => { 262 | const updateProductoDto = new UpdateProductoDto() 263 | 264 | const mockProductoEntity = new ProductoEntity() 265 | const mockResponseProductoDto = new ResponseProductoDto() 266 | const mockCategoriaEntity = new CategoriaEntity() 267 | 268 | const mockQueryBuilder = { 269 | leftJoinAndSelect: jest.fn().mockReturnThis(), 270 | orderBy: jest.fn().mockReturnThis(), 271 | where: jest.fn().mockReturnThis(), // Añade esto 272 | getOne: jest.fn().mockResolvedValue(mockProductoEntity), 273 | } 274 | 275 | jest 276 | .spyOn(productoRepository, 'createQueryBuilder') 277 | .mockReturnValue(mockQueryBuilder as any) 278 | 279 | jest 280 | .spyOn(service, 'checkCategoria') 281 | .mockResolvedValue(mockCategoriaEntity) 282 | 283 | jest.spyOn(service, 'exists').mockResolvedValue(mockProductoEntity) 284 | 285 | jest 286 | .spyOn(productoRepository, 'save') 287 | .mockResolvedValue(mockProductoEntity) 288 | 289 | jest 290 | .spyOn(mapper, 'toResponseDto') 291 | .mockReturnValue(mockResponseProductoDto) 292 | 293 | expect(await service.update(1, updateProductoDto)).toEqual( 294 | mockResponseProductoDto, 295 | ) 296 | }) 297 | }) 298 | 299 | describe('remove', () => { 300 | it('should remove a producto', async () => { 301 | const mockProductoEntity = new ProductoEntity() 302 | const mockResponseProductoDto = new ResponseProductoDto() 303 | 304 | jest.spyOn(service, 'exists').mockResolvedValue(mockProductoEntity) 305 | 306 | jest 307 | .spyOn(productoRepository, 'remove') 308 | .mockResolvedValue(mockProductoEntity) 309 | 310 | jest 311 | .spyOn(mapper, 'toResponseDto') 312 | .mockReturnValue(mockResponseProductoDto) 313 | 314 | expect(await service.remove(1)).toEqual(mockResponseProductoDto) 315 | }) 316 | }) 317 | 318 | describe('removeSoft', () => { 319 | it('should soft remove a producto', async () => { 320 | const mockProductoEntity = new ProductoEntity() 321 | const mockResponseProductoDto = new ResponseProductoDto() 322 | 323 | jest.spyOn(service, 'exists').mockResolvedValue(mockProductoEntity) 324 | 325 | jest 326 | .spyOn(productoRepository, 'save') 327 | .mockResolvedValue(mockProductoEntity) 328 | 329 | jest 330 | .spyOn(mapper, 'toResponseDto') 331 | .mockReturnValue(mockResponseProductoDto) 332 | 333 | expect(await service.removeSoft(1)).toEqual(mockResponseProductoDto) 334 | }) 335 | }) 336 | 337 | describe('exists', () => { 338 | const result = new ProductoEntity() 339 | it('should return true if product exists', async () => { 340 | const id = 1 341 | jest 342 | .spyOn(productoRepository, 'findOneBy') 343 | .mockResolvedValue(new ProductoEntity()) 344 | 345 | expect(await service.exists(id)).toEqual(result) 346 | }) 347 | 348 | it('should return false if product does not exist', async () => { 349 | const id = 1 350 | jest.spyOn(productoRepository, 'findOneBy').mockResolvedValue(undefined) 351 | 352 | await expect(service.exists(id)).rejects.toThrow(NotFoundException) 353 | }) 354 | }) 355 | 356 | describe('checkCategoria', () => { 357 | it('should return true if category exists', async () => { 358 | const categoria = new CategoriaEntity() 359 | const categoriaNombre = 'some-category' 360 | 361 | const mockQueryBuilder = { 362 | where: jest.fn().mockReturnThis(), // Añade esto 363 | getOne: jest.fn().mockResolvedValue(categoria), 364 | } 365 | 366 | jest 367 | .spyOn(categoriaRepository, 'createQueryBuilder') 368 | .mockReturnValue(mockQueryBuilder as any) 369 | 370 | expect(await service.checkCategoria(categoriaNombre)).toBe(categoria) 371 | }) 372 | 373 | it('should return false if category does not exist', async () => { 374 | const categoriaNombre = 'some-category' 375 | 376 | const mockQueryBuilder = { 377 | where: jest.fn().mockReturnThis(), // Añade esto 378 | getOne: jest.fn().mockResolvedValue(undefined), 379 | } 380 | 381 | jest 382 | .spyOn(categoriaRepository, 'createQueryBuilder') 383 | .mockReturnValue(mockQueryBuilder as any) 384 | 385 | await expect(service.checkCategoria(categoriaNombre)).rejects.toThrow( 386 | BadRequestException, 387 | ) 388 | }) 389 | }) 390 | 391 | describe('updateImage', () => { 392 | it('should update a producto image', async () => { 393 | const mockRequest = { 394 | protocol: 'http', 395 | get: () => 'localhost', 396 | } 397 | const mockFile = { 398 | filename: 'new_image', 399 | } 400 | 401 | const mockProductoEntity = new ProductoEntity() 402 | const mockResponseProductoDto = new ResponseProductoDto() 403 | 404 | jest.spyOn(service, 'exists').mockResolvedValue(mockProductoEntity) 405 | 406 | jest 407 | .spyOn(productoRepository, 'save') 408 | .mockResolvedValue(mockProductoEntity) 409 | 410 | jest 411 | .spyOn(mapper, 'toResponseDto') 412 | .mockReturnValue(mockResponseProductoDto) 413 | 414 | expect( 415 | await service.updateImage(1, mockFile as any, mockRequest as any, true), 416 | ).toEqual(mockResponseProductoDto) 417 | 418 | expect(storageService.removeFile).toHaveBeenCalled() 419 | expect(storageService.getFileNameWithouUrl).toHaveBeenCalled() 420 | }) 421 | }) 422 | }) 423 | --------------------------------------------------------------------------------