diff --git a/src/auth/casl/casl-ability.factory.ts b/src/auth/casl/casl-ability.factory.ts index 8d394c78..8ad08aa3 100644 --- a/src/auth/casl/casl-ability.factory.ts +++ b/src/auth/casl/casl-ability.factory.ts @@ -9,13 +9,13 @@ import { Injectable } from '@nestjs/common'; import { Permission } from '../enums/permission.enum'; import { User } from '../../users/entities/user.entity'; import { FlatClass } from '../types/flat-class.type'; +import { Favorite } from '../../favorites/entities/favorite.entity'; import { History } from '../../history/models/history.entity'; import { Cart } from '../../cart/entities/cart.entity'; import { CustomMessage } from '../../custom-messages/entities/custom-messages.entity'; import { Item } from '../../items/entities/items.entity'; - // TODO: add classes to InferSubjects -> InferSubjects -type Subjects = InferSubjects | 'all'; +type Subjects = InferSubjects | 'all'; export type AppAbility = Ability<[Permission, Subjects]>; @@ -38,6 +38,14 @@ export class CaslAbilityFactory { 'user.id': user.id, }); + can>([ + Permission.Read, + Permission.Create, + Permission.Delete + ], Favorite, { + 'user.id': user.id, + }); + can>( [Permission.Read, Permission.Delete, Permission.Update], CustomMessage, diff --git a/src/favorites/controllers/favorites.controller.spec.ts b/src/favorites/controllers/favorites.controller.spec.ts index 24755bf9..231fa7c7 100644 --- a/src/favorites/controllers/favorites.controller.spec.ts +++ b/src/favorites/controllers/favorites.controller.spec.ts @@ -1,25 +1,57 @@ +import { + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { plainToClass } from 'class-transformer'; +import { CaslModule } from '../../auth/casl/casl.module'; +import STATUS from '../../statusCodes/statusCodes'; +import { User } from '../../users/entities/user.entity'; +import { CreateFavoriteDto } from '../dto/create-favorite.dto'; +import { Favorite } from '../entities/favorite.entity'; import { FavoritesService } from '../services/favorites.service'; import { FavoritesController } from './favorites.controller'; describe('FavoritesController', () => { let controller: FavoritesController; + const user: User = new User(); + user.id = 1; + const mockFavoriteService = { - paginate: jest.fn(), - create: jest.fn(dto => { - return { - ...dto, - user_id: 24 - } + paginate: jest.fn().mockResolvedValue([ + { + id: 1, + user: 1, + item_id: 1, + createdAt: new Date(), + }, + ]), + create: jest.fn((user: User, body: CreateFavoriteDto) => { + Promise.resolve({ + item_id: 1, + }); }), - delete: jest.fn() - } + delete: jest.fn(), + findFavorite: jest.fn().mockImplementation((id) => + Promise.resolve( + plainToClass(Favorite, { + user: user, + id: id, + }), + ), + ), + }; + + const response: any = { + user: new User(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [FavoritesController], - providers: [FavoritesService] + providers: [FavoritesService], + imports: [CaslModule], }) .overrideProvider(FavoritesService) .useValue(mockFavoriteService) @@ -32,28 +64,77 @@ describe('FavoritesController', () => { expect(controller).toBeDefined(); }); - it('should get 10 favorites records.', () => { - const page = 1 - const limit = 10 - const token = 1 - expect(controller.paginate(token, page, limit)).not.toBeUndefined() - expect(mockFavoriteService.paginate).toHaveBeenCalled() - }) - - it('should create a favorite.', () => { - const data = { item_id: 61 } - const token = 1 - expect(controller.create(token, data)).toEqual({ - "message": "Created", - "statusCode": 201, + describe('paginate', () => { + it('should get 10 favorites records.', async () => { + const page = 1; + const limit = 10; + const route = '/favorites'; + + const user: User = new User(); + user.id = 1; + + await expect(controller.paginate(response, page, limit)).resolves.toEqual( + [ + { + id: 1, + user: 1, + item_id: 1, + createdAt: expect.any(Date), + }, + ], + ); + + expect(mockFavoriteService.paginate).toHaveBeenCalledWith( + { page, limit, route }, + response.user, + ); + }); + }); + + describe('create', () => { + it('should create a favorite.', async () => { + const mockUser = new User(); + mockUser.id = 1; + + const request: any = { + user: mockUser, + }; + request.user.id = 1; + + const dto: CreateFavoriteDto = { + itemId: 1, + }; + + expect(await controller.create(request, dto)).toEqual(STATUS.CREATED); + + expect(mockFavoriteService.create).toHaveBeenCalledWith( + dto, + request.user, + ); }); - expect(mockFavoriteService.create).toHaveBeenCalledWith(data, token) }); - it('should delete a favorite.', () => { - const okResponse = { "message": "Ok", "statusCode": 200} - const favoriteId = 1 - expect(controller.delete(favoriteId)).toStrictEqual(okResponse) - expect(mockFavoriteService.delete).toHaveBeenCalledWith(favoriteId) - }) + describe('delete', () => { + it('should delete a favorite.', async () => { + const request: any = { + user: user, + }; + + expect(await controller.delete(request, 1)).toEqual(STATUS.DELETED); + }); + + it('should return a ForbiddenException', async () => { + await expect(controller.delete(response, 1)).rejects.toThrowError( + ForbiddenException, + ); + }); + + it('should return a NotFoundException', async () => { + mockFavoriteService.findFavorite.mockRejectedValueOnce(new NotFoundException()); + + await expect(controller.delete(response, 1)).rejects.toThrowError( + NotFoundException, + ); + }); + }); }); diff --git a/src/favorites/controllers/favorites.controller.ts b/src/favorites/controllers/favorites.controller.ts index 09e39f81..e09b3cd7 100644 --- a/src/favorites/controllers/favorites.controller.ts +++ b/src/favorites/controllers/favorites.controller.ts @@ -6,35 +6,50 @@ import { HttpCode, Param, Post, - Patch, ParseIntPipe, DefaultValuePipe, Query, - HttpException, - HttpStatus, + Req, + NotFoundException, + ForbiddenException, + UnauthorizedException, + BadRequestException, + UnprocessableEntityException, } from '@nestjs/common'; - import { + ApiBadRequestResponse, + ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiForbiddenResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, - ApiTags + ApiTags, + ApiUnauthorizedResponse, + ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; - import { FavoritesService } from '../services/favorites.service'; - import { CreateFavoriteDto } from '../dto/create-favorite.dto'; import { Favorite } from '../entities/favorite.entity'; import { Pagination } from 'nestjs-typeorm-paginate'; +import { Request } from 'express'; +import { CaslAbilityFactory } from '../../auth/casl/casl-ability.factory'; +import { Permission } from '../../auth/enums/permission.enum'; +import { subject } from '@casl/ability'; +import { User } from '../../users/entities/user.entity'; +import STATUS from '../../statusCodes/statusCodes'; +import Status from '../../statusCodes/status.interface'; @ApiTags('Favorites') @Controller('favorites') export class FavoritesController { - constructor(private readonly favoritesService: FavoritesService) {} + constructor( + private readonly favoritesService: FavoritesService, + private readonly caslAbilityFactory: CaslAbilityFactory, + ) {} @Get() @HttpCode(200) @@ -44,130 +59,141 @@ export class FavoritesController { description: 'List of favorites records. Maximum 10 records per page.', schema: { example: { - "items": [ + items: [ { - "id": 31, - "item": { - "photos": "https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80", - "title": "Lorem ipsum dolor sit amet, consectetuer adipiscin", - "description": "More Lorem ipsum and dolorems", - "price": 1050.99, - "stock": 10 - } + id: 31, + item: { + photos: + 'https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80', + title: 'Lorem ipsum dolor sit amet, consectetuer adipiscin', + description: 'More Lorem ipsum and dolorems', + price: 1050.99, + stock: 10, + }, }, { - "id": 32, - "item": { - "photos": "https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80", - "title": "Otra publicacion", - "description": "Otra descripcion", - "price": 250.99, - "stock": 10 - } + id: 32, + item: { + photos: + 'https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80', + title: 'Otra publicacion', + description: 'Otra descripcion', + price: 250.99, + stock: 10, + }, }, { - "id": 35, - "item": { - "photos": "https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80", - "title": "Da Da Da - original Song", - "description": "Da DA DA!", - "price": 6450, - "stock": 104 - } + id: 35, + item: { + photos: + 'https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80', + title: 'Da Da Da - original Song', + description: 'Da DA DA!', + price: 6450, + stock: 104, + }, }, { - "id": 37, - "item": { - "photos": "https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80", - "title": "La Vaca Lola - original Song", - "description": "No me acuerdo la letra", - "price": 1320, - "stock": 12 - } + id: 37, + item: { + photos: + 'https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80', + title: 'La Vaca Lola - original Song', + description: 'No me acuerdo la letra', + price: 1320, + stock: 12, + }, }, { - "id": 39, - "item": { - "photos": "https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80", - "title": "La computadora de Canale", - "description": "Un poco usada, pero va.", - "price": 100000, - "stock": 181 - } + id: 39, + item: { + photos: + 'https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80', + title: 'La computadora de Canale', + description: 'Un poco usada, pero va.', + price: 100000, + stock: 181, + }, }, { - "id": 41, - "item": { - "photos": "https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80", - "title": "Una galletita encontrada en el suelo", - "description": "Tiene pelos", - "price": 2, - "stock": 1 - } + id: 41, + item: { + photos: + 'https://images.unsplash.com/photo-1633114128729-0a8dc13406b9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=870&q=80', + title: 'Una galletita encontrada en el suelo', + description: 'Tiene pelos', + price: 2, + stock: 1, + }, }, { - "id": 33, - "item": { - "photos": "https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80", - "title": "Una publicacion", - "description": "Una descripcion", - "price": 2250.99, - "stock": 104 - } + id: 33, + item: { + photos: + 'https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80', + title: 'Una publicacion', + description: 'Una descripcion', + price: 2250.99, + stock: 104, + }, }, { - "id": 36, - "item": { - "photos": "https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80", - "title": "La Cucaracha - original Song", - "description": "Ya no puede caminar", - "price": 130, - "stock": 54 - } + id: 36, + item: { + photos: + 'https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80', + title: 'La Cucaracha - original Song', + description: 'Ya no puede caminar', + price: 130, + stock: 54, + }, }, { - "id": 38, - "item": { - "photos": "https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80", - "title": "La peluca de Agustín", - "description": "Usada en la Demo Day.", - "price": 1440, - "stock": 11 - } + id: 38, + item: { + photos: + 'https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80', + title: 'La peluca de Agustín', + description: 'Usada en la Demo Day.', + price: 1440, + stock: 11, + }, }, { - "id": 40, - "item": { - "photos": "https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80", - "title": "La Moto de Canale", - "description": "Pinchada la rueda, el asiento tiene olor feo pero sirve.", - "price": 140000, - "stock": 181 - } - } + id: 40, + item: { + photos: + 'https://images.unsplash.com/photo-1638486071991-860cc6b14338?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=387&q=80', + title: 'La Moto de Canale', + description: + 'Pinchada la rueda, el asiento tiene olor feo pero sirve.', + price: 140000, + stock: 181, + }, + }, ], - "meta": { - "totalItems": 39, - "itemCount": 10, - "itemsPerPage": 10, - "totalPages": 2, - "currentPage": 1 + meta: { + totalItems: 39, + itemCount: 10, + itemsPerPage: 10, + totalPages: 2, + currentPage: 1, + }, + links: { + first: '/favorites', + previous: '', + next: '/favorites?page=2', + last: '/favorites?page=4', }, - "links": { - "first": "/favorites", - "previous": "", - "next": "/favorites?page=2", - "last": "/favorites?page=4" - } }, - } + }, }) - @ApiQuery({ - name: 'token', - type: Number, - required: true, - description: 'Token.', - example: 1, + @ApiBearerAuth() + @ApiUnauthorizedResponse({ + description: 'Not Authorized', + schema: { + example: new UnauthorizedException().getResponse(), + }, }) @ApiQuery({ name: 'page', @@ -176,20 +202,26 @@ export class FavoritesController { description: 'Current page number. Default value: 1.', example: 1, }) + @ApiQuery({ + required: false, + name: 'limit', + description: 'Max amount of items per page', + example: 10, + }) public async paginate( - @Query('token') token: number, + @Req() req: Request, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page = 1, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit = 10, ): Promise> { - limit = 10 - token = 1 + const user: User = req.user; + return this.favoritesService.paginate( { page, limit, route: '/favorites', }, - token + user, ); } @@ -200,13 +232,44 @@ export class FavoritesController { description: 'Forbidden', schema: { example: { - "statusCode": 403, - "message": "Forbidden" - } - } + example: new ForbiddenException().getResponse(), + }, + }, + }) + @ApiNotFoundResponse({ + description: 'Favorites Not Found', + schema: { + example: new NotFoundException('Favorites not found').getResponse(), + }, + }) + @ApiUnauthorizedResponse({ + description: 'Not Authorized', + schema: { + example: new UnauthorizedException().getResponse(), + }, + }) + @ApiBearerAuth() + @ApiParam({ + name: 'id', + type: Number, + required: true, + description: 'The ID of the Favorite record to delete.', + example: 1, }) - public async getById() { - throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); + public async getById(@Param('id') id: number, @Req() req: Request) { + const user: User = req.user; + + const favorite = await this.favoritesService.findFavorite(id); + + if (!favorite) { + throw new NotFoundException('Favorite not found.'); + } + + const ability = this.caslAbilityFactory.createForUser(user); + + if (ability.cannot(Permission.Read, subject('Favorite', favorite))) { + throw new ForbiddenException(); + } } @Post() @@ -217,45 +280,44 @@ export class FavoritesController { description: 'Created', schema: { example: { - "statusCode": 201, - "message": "Created" - } - } + statusCode: 201, + message: 'Created', + }, + }, }) - @ApiBody({ type: CreateFavoriteDto }) - @ApiQuery({ - name: 'token', - type: Number, - required: true, - description: 'Token.', - example: 1, + @ApiBearerAuth() + @ApiUnauthorizedResponse({ + description: 'Not Authorized', + schema: { + example: new UnauthorizedException().getResponse(), + }, }) - public create( - @Query('token') token: number, - @Body() createFavoriteDto: CreateFavoriteDto - ) { - token = 1 - this.favoritesService.create(createFavoriteDto, token); - return ({ - "statusCode": 201, - "message": "Created" - }) - } - - @Patch() - @HttpCode(403) - @ApiForbiddenResponse({ - status: 403, - description: 'Forbidden', + @ApiBadRequestResponse({ + description: 'Validation error', schema: { - example: { - "statusCode": 403, - "message": "Forbidden" - } - } + example: new BadRequestException([ + 'the itemId must be a number', + ]).getResponse(), + }, }) - public update() { - throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); + @ApiUnprocessableEntityResponse({ + description: 'The itemId supplied does not exist', + schema: { + example: new UnprocessableEntityException( + 'Item does not exist', + ).getResponse(), + }, + }) + @ApiBody({ type: CreateFavoriteDto }) + public async create( + @Req() req: Request, + @Body() createFavoriteDto: CreateFavoriteDto, + ) { + const user: any = req.user; + + this.favoritesService.create(createFavoriteDto, user); + + return STATUS.CREATED; } @Delete(':id') @@ -265,12 +327,28 @@ export class FavoritesController { status: 200, description: 'Ok', schema: { - example: { - "statusCode": 200, - "message": "Ok" - } - } + example: STATUS.DELETED, + }, + }) + @ApiUnauthorizedResponse({ + description: 'Not Authorized', + schema: { + example: new UnauthorizedException().getResponse(), + }, + }) + @ApiForbiddenResponse({ + description: 'Forbidden.', + schema: { + example: new ForbiddenException().getResponse(), + }, + }) + @ApiNotFoundResponse({ + description: 'Favorites Not Found', + schema: { + example: new NotFoundException('Favorites not found').getResponse(), + }, }) + @ApiBearerAuth() @ApiParam({ name: 'id', type: Number, @@ -278,11 +356,26 @@ export class FavoritesController { description: 'The ID of the Favorite record to delete.', example: 1, }) - public delete(@Param('id') id: number) { + public async delete( + @Req() req: Request, + @Param('id') id: number, + ): Promise { + const user: User = req.user; + + const favorite = await this.favoritesService.findFavorite(id); + + if (!favorite) { + throw new NotFoundException('Favorite not found.'); + } + + const ability = this.caslAbilityFactory.createForUser(user); + + if (ability.cannot(Permission.Delete, subject('Favorite', favorite))) { + throw new ForbiddenException(); + } + this.favoritesService.delete(id); - return ({ - "statusCode": 200, - "message": "Ok" - }) + + return STATUS.DELETED; } } diff --git a/src/favorites/dto/create-favorite.dto.ts b/src/favorites/dto/create-favorite.dto.ts index cd73554e..c4a35aad 100644 --- a/src/favorites/dto/create-favorite.dto.ts +++ b/src/favorites/dto/create-favorite.dto.ts @@ -1,7 +1,11 @@ -import { OmitType, PartialType } from "@nestjs/swagger"; -import { FavoriteDto } from "./favorite.dto"; +import { IsNumber } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; -export class CreateFavoriteDto extends PartialType( - OmitType(FavoriteDto, ['id', 'user_id', 'updatedAt'] as const), -) { +export class CreateFavoriteDto { + @IsNumber() + @ApiProperty({ + example: '1', + description: 'represents the unique identifier of the item', + }) + itemId: number; } diff --git a/src/favorites/dto/favorite.dto.ts b/src/favorites/dto/favorite.dto.ts deleted file mode 100644 index 2d46639b..00000000 --- a/src/favorites/dto/favorite.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsInt, IsDateString } from "class-validator"; - -export class FavoriteDto { - @ApiProperty({ - type: Number, - description: 'The ID of the Favorite record.', - example: 1, - }) - @IsInt() - id: number; - - @ApiProperty({ - type: Number, - description: 'The ID of the User record.', - example: 2, - }) - @IsInt() - user_id: number; - - @ApiProperty({ - type: Number, - description: 'The ID of the Item record.', - example: 3, - }) - @IsInt() - item_id: number; - - @ApiProperty({ - type: Date, - description: 'The date the record was last updated', - example: '2021-11-15 17:32:19.537+00', - }) - @IsDateString() - updatedAt: Date; -} - diff --git a/src/favorites/entities/favorite.entity.ts b/src/favorites/entities/favorite.entity.ts index 5ccd23a6..f57c457f 100644 --- a/src/favorites/entities/favorite.entity.ts +++ b/src/favorites/entities/favorite.entity.ts @@ -1,4 +1,5 @@ import { + DeleteDateColumn, Entity, JoinColumn, PrimaryGeneratedColumn, @@ -6,7 +7,6 @@ import { } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { ManyToOne } from 'typeorm'; - import { User } from '../../users/entities/user.entity'; import { Item } from '../../items/entities/items.entity'; @@ -29,24 +29,24 @@ export class Favorite { @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) @ApiProperty({ - type: 'integer', + type: 'User', description: 'The user ID who added the item to favorites', nullable: false, readOnly: true, example: 8, }) - user_id: number; + user: User; @ManyToOne(() => Item) @JoinColumn({ name: 'item_id' }) @ApiProperty({ - type: 'integer', + type: 'Item', description: 'The Item ID that was added to favorites', nullable: false, readOnly: true, example: 3, }) - item_id: number; + item: Item; @ApiProperty({ type: Date, @@ -60,5 +60,18 @@ export class Favorite { type: 'timestamptz', nullable: false, }) - updated_at: Date; + updatedAt: Date; + + @ApiProperty({ + type: Date, + format: 'date', + default: 'now()', + description: 'The date the record was last deleted', + example: '2021-11-15 17:32:19.537+00', + }) + @DeleteDateColumn({ + name: 'deleted_at', + type: 'timestamptz', + }) + deletedAt: Date; } diff --git a/src/favorites/favorites.module.ts b/src/favorites/favorites.module.ts index 16e7e1a0..bcf98922 100644 --- a/src/favorites/favorites.module.ts +++ b/src/favorites/favorites.module.ts @@ -4,9 +4,11 @@ import { FavoritesController } from './controllers/favorites.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Favorite } from './entities/favorite.entity'; +import { CaslModule } from '../auth/casl/casl.module'; +import { Item } from '../items/entities/items.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Favorite])], + imports: [TypeOrmModule.forFeature([Favorite, Item]), CaslModule], providers: [FavoritesService], controllers: [FavoritesController], }) diff --git a/src/favorites/services/favorites.service.spec.ts b/src/favorites/services/favorites.service.spec.ts index 1366b70b..a80f9d8b 100644 --- a/src/favorites/services/favorites.service.spec.ts +++ b/src/favorites/services/favorites.service.spec.ts @@ -1,5 +1,32 @@ +const itemsList = [ + { + id: 1, + user: 1, + item_id: 1, + createdAt: Date.now(), + }, +]; + +const list = { + items: itemsList.slice(0, 2), + meta: { + itemCount: 2, + totalItems: 2, + totalPages: 1, + currentPage: 1, + }, +}; + +jest.mock('nestjs-typeorm-paginate', () => ({ + paginate: jest.fn().mockResolvedValue(list), +})); + import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { plainToClass } from 'class-transformer'; +import { IPaginationOptions } from 'nestjs-typeorm-paginate'; +import { Item } from '../../items/entities/items.entity'; +import { User } from '../../users/entities/user.entity'; import { CreateFavoriteDto } from '../dto/create-favorite.dto'; import { Favorite } from '../entities/favorite.entity'; import { FavoritesService } from './favorites.service'; @@ -8,22 +35,65 @@ describe('FavoritesService', () => { let service: FavoritesService; const mockFavoriteRepository = { - paginate: jest.fn(), - create: jest.fn(), - save: jest.fn().mockImplementation(favorite => Promise.resolve({ - id: Date.now(), - ...favorite, - updated_at: Date.now() + paginate: jest.fn().mockResolvedValue({ + page: '1', + limit: '10', + }), + create: jest.fn((body: CreateFavoriteDto) => ({ + itemId: body.itemId, })), - delete: jest.fn().mockImplementation(() => Promise.resolve()) - } + save: jest.fn().mockImplementation((favorite) => + Promise.resolve({ + id: Date.now(), + ...favorite, + updated_at: Date.now(), + }), + ), + softDelete: jest.fn().mockResolvedValue({ + itemId: 62, + }), + }; + + const userId = 1; + + const mockUser = plainToClass(User, { + id: userId, + username: null, + password: '5vOC1yGAT2Km0Lt', + email: 'Dewitt.Turcotte52@hotmail.com', + }); + + const genericItem = plainToClass(Item, { + id: 3, + createdAt: new Date(), + updatedAt: new Date(), + title: 'Name 2', + description: 'Des 2', + price: 2, + stock: 2, + user: mockUser, + }); + + const mockItemsRepository = { + findOne: jest.fn( + (id: number): Promise => + Promise.resolve(id == 1 ? genericItem : null), + ), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [FavoritesService , { - provide: getRepositoryToken(Favorite), - useValue: mockFavoriteRepository - }], + providers: [ + FavoritesService, + { + provide: getRepositoryToken(Favorite), + useValue: mockFavoriteRepository, + }, + { + provide: getRepositoryToken(Item), + useValue: mockItemsRepository, + }, + ], }).compile(); service = module.get(FavoritesService); @@ -33,17 +103,52 @@ describe('FavoritesService', () => { expect(service).toBeDefined(); }); - it('should create a new favorite record.', async () => { - const createFavoriteDto: CreateFavoriteDto = { item_id: 62 } - const token = 1 - expect(await service.create(createFavoriteDto, token)).toBeUndefined() - expect(mockFavoriteRepository.create).toHaveBeenCalledWith({...createFavoriteDto, "user_id": token}) - expect(mockFavoriteRepository.save).toHaveBeenCalled() - }) - - it('should delete a favorite.', async () => { - const itemid = 1 - expect(await service.delete(itemid)).toBeUndefined() - expect(mockFavoriteRepository.delete).toHaveBeenCalledWith(itemid) - }) + describe('paginate', () => { + it('should return an favorite pagination', async () => { + const options: IPaginationOptions = { + page: 1, + limit: 10, + }; + + expect(await service.paginate(options, new User())).toEqual(list); + }); + }); + + describe('create', () => { + it('should create a new favorite record.', async () => { + const createFavoriteDto: CreateFavoriteDto = { itemId: 1 }; + + const user: User = new User(); + user.id = 1; + + const expected = { + itemId: 1, + }; + + const receivesCreate = { + itemId: 1, + user: { + id: 1, + }, + }; + + expect(await service.create(createFavoriteDto, user)).toBeUndefined(); + + expect(mockFavoriteRepository.create).toHaveBeenCalledWith( + receivesCreate, + ); + + expect(mockFavoriteRepository.save).toHaveBeenCalledWith(expected); + }); + }); + + describe('delete', () => { + it('should delete a favorite.', async () => { + const itemid = 62; + + expect(await service.delete(itemid)).toBeUndefined(); + + expect(mockFavoriteRepository.softDelete).toHaveBeenCalledWith(itemid); + }); + }); }); diff --git a/src/favorites/services/favorites.service.ts b/src/favorites/services/favorites.service.ts index ce3077f1..81cadc91 100644 --- a/src/favorites/services/favorites.service.ts +++ b/src/favorites/services/favorites.service.ts @@ -1,62 +1,59 @@ import { Repository } from 'typeorm'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { paginate, IPaginationOptions, Pagination, } from 'nestjs-typeorm-paginate'; - import { CreateFavoriteDto } from '../dto/create-favorite.dto'; import { Favorite } from '../entities/favorite.entity'; -import { PhotosEntity } from '../../photos/models/photos.entity'; +import { User } from '../../users/entities/user.entity'; +import { Item } from '../../items/entities/items.entity'; @Injectable() export class FavoritesService { constructor( @InjectRepository(Favorite) private readonly favoritesRepo: Repository, + @InjectRepository(Item) + private readonly itemRepo: Repository, ) {} async paginate( options: IPaginationOptions, - token: number, + user: User, ): Promise> { - const favorites = this.favoritesRepo - .createQueryBuilder('favorite') - .where('favorite.user_id = :token', { token }) - .leftJoinAndMapOne('favorite.item', 'favorite.item_id', 'items') - .leftJoinAndMapOne( - 'items.photos', - PhotosEntity, - 'photos', - 'favorite.item_id = photos.subject_id and photos.subject_type = :item', - { item: 'item' }, - ) - .select(['favorite.id']) - .addSelect([ - 'items.title', - 'items.description', - 'items.price', - 'items.stock', - 'photos.url', - ]); - - return paginate(favorites, options); + return paginate(this.favoritesRepo, options, { + where: { user: { id: user.id } }, + relations: ['item'], + }); } - async create( - createFavoriteDto: CreateFavoriteDto, - token: number, - ): Promise { - const preFavorite = { ...createFavoriteDto, user_id: token }; + async create(body: CreateFavoriteDto, user: User): Promise { + const itemExist = await this.itemRepo.findOne(body.itemId); + + if (!itemExist) { + throw new UnprocessableEntityException('Item does not exist'); + } + + const preFavorite: any = { ...body, user: user }; + const newFavorite = this.favoritesRepo.create(preFavorite); + await this.favoritesRepo.save(newFavorite); - return; } async delete(id: number): Promise { - await this.favoritesRepo.delete(id); - return; + await this.favoritesRepo.softDelete(id); + } + + async findFavorite(id: number): Promise { + const favorite = await this.favoritesRepo.findOne(id, { + select: ['id'], + relations: ['user', 'item'], + }); + + return favorite; } }