From 06f859423c66c5ade76fff73f00ca1d9f8a3073f Mon Sep 17 00:00:00 2001 From: Carlos Mesquita <52337966+carlos3g@users.noreply.github.com> Date: Tue, 27 Aug 2024 13:02:24 -0300 Subject: [PATCH] feat: [WIP] favorite quotes see: https://github.com/carlos3g/echoes-api/issues/1 --- prisma/dbml/schema.dbml | 14 ++-- prisma/schema.prisma | 10 +-- .../contracts/quote-repository.contract.ts | 3 + src/quote/dtos/favorite-quote-input.ts | 4 ++ src/quote/dtos/quote-repository-dtos.ts | 6 ++ src/quote/quote.controller.e2e-spec.ts | 24 ++++++- src/quote/quote.controller.ts | 21 +++--- src/quote/quote.module.ts | 5 +- .../prisma-quote.repository.e2e-spec.ts | 70 ++++++++++++++++++- .../repositories/prisma-quote.repository.ts | 5 ++ src/quote/services/quote.service.ts | 6 +- .../use-cases/favorite-quote.use-case.ts | 33 +++++++++ 12 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 src/quote/dtos/favorite-quote-input.ts create mode 100644 src/quote/use-cases/favorite-quote.use-case.ts diff --git a/prisma/dbml/schema.dbml b/prisma/dbml/schema.dbml index 476608f..2726a58 100644 --- a/prisma/dbml/schema.dbml +++ b/prisma/dbml/schema.dbml @@ -13,7 +13,7 @@ Table users { tags tags [not null] createdAt DateTime [default: `now()`, not null] updatedAt DateTime [not null] - userOnFavoritable tag_on_favoritable [not null] + userOnFavoritable user_on_favoritable [not null] } Table password_change_request { @@ -35,7 +35,7 @@ Table quotes { createdAt DateTime [default: `now()`, not null] updatedAt DateTime [not null] tagOnTaggable tag_on_taggable [not null] - userOnFavoritable tag_on_favoritable [not null] + userOnFavoritable user_on_favoritable [not null] } Table sources { @@ -68,7 +68,7 @@ Table authors { createdAt DateTime [default: `now()`, not null] updatedAt DateTime [not null] tagOnTaggable tag_on_taggable [not null] - userOnFavoritable tag_on_favoritable [not null] + userOnFavoritable user_on_favoritable [not null] } Table tags { @@ -95,7 +95,7 @@ Table tag_on_taggable { } } -Table tag_on_favoritable { +Table user_on_favoritable { user users [not null] userId BigInt [not null] author authors @@ -137,8 +137,8 @@ Ref: tag_on_taggable.taggableId > authors.id Ref: tag_on_taggable.taggableId > quotes.id -Ref: tag_on_favoritable.userId > users.id +Ref: user_on_favoritable.userId > users.id -Ref: tag_on_favoritable.favoritableId > authors.id +Ref: user_on_favoritable.favoritableId > authors.id -Ref: tag_on_favoritable.favoritableId > quotes.id \ No newline at end of file +Ref: user_on_favoritable.favoritableId > quotes.id \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ac4dce..ae8b701 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,7 +50,7 @@ model Quote { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tagOnTaggable TagOnTaggable[] - userOnFavoritable UserOnFavoritable[] + userOnFavoritable UserOnFavoritable[] @relation("UserOnQuote") @@map("quotes") } @@ -89,7 +89,7 @@ model Author { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tagOnTaggable TagOnTaggable[] - userOnFavoritable UserOnFavoritable[] + userOnFavoritable UserOnFavoritable[] @relation("UserOnAuthor") @@map("authors") } @@ -132,11 +132,11 @@ enum FavoritableType { model UserOnFavoritable { user User @relation(fields: [userId], references: [id]) userId BigInt @db.BigInt - author Author? @relation(fields: [favoritableId], references: [id], map: "author_favoritableId") - quote Quote? @relation(fields: [favoritableId], references: [id], map: "quote_favoritableId") + author Author? @relation("UserOnAuthor", fields: [favoritableId], references: [id], map: "author_favoritableId") + quote Quote? @relation("UserOnQuote", fields: [favoritableId], references: [id], map: "quote_favoritableId") favoritableId BigInt @map("favoritable_id") @db.BigInt favoritableType FavoritableType @map("favoritable_type") @@unique([userId, favoritableId, favoritableType]) - @@map("tag_on_favoritable") + @@map("user_on_favoritable") } diff --git a/src/quote/contracts/quote-repository.contract.ts b/src/quote/contracts/quote-repository.contract.ts index 08a8a92..f18e0ed 100644 --- a/src/quote/contracts/quote-repository.contract.ts +++ b/src/quote/contracts/quote-repository.contract.ts @@ -2,6 +2,7 @@ import type { PaginatedResult } from '@app/lib/prisma/helpers/pagination'; import type { QuoteRepositoryCreateInput, QuoteRepositoryDeleteInput, + QuoteRepositoryFindManyFavoritedByUserInput, QuoteRepositoryFindManyInput, QuoteRepositoryFindManyPaginatedInput, QuoteRepositoryFindUniqueOrThrowInput, @@ -18,6 +19,8 @@ abstract class QuoteRepositoryContract { public abstract findManyPaginated(input: QuoteRepositoryFindManyPaginatedInput): Promise>; + public abstract findManyFavoritedByUser(input?: QuoteRepositoryFindManyFavoritedByUserInput): Promise; + public abstract findMany(input?: QuoteRepositoryFindManyInput): Promise; public abstract delete(input: QuoteRepositoryDeleteInput): Promise; diff --git a/src/quote/dtos/favorite-quote-input.ts b/src/quote/dtos/favorite-quote-input.ts new file mode 100644 index 0000000..bf9b697 --- /dev/null +++ b/src/quote/dtos/favorite-quote-input.ts @@ -0,0 +1,4 @@ +export interface FavoriteQuoteInput { + quoteUuid: string; + userUuid: string; +} diff --git a/src/quote/dtos/quote-repository-dtos.ts b/src/quote/dtos/quote-repository-dtos.ts index 65b26af..03eb72c 100644 --- a/src/quote/dtos/quote-repository-dtos.ts +++ b/src/quote/dtos/quote-repository-dtos.ts @@ -28,6 +28,12 @@ export interface QuoteRepositoryFindManyInput { }; } +export interface QuoteRepositoryFindManyFavoritedByUserInput { + where: { + userId: number; + }; +} + export interface QuoteRepositoryFindManyPaginatedInput { where?: { authorId?: number; diff --git a/src/quote/quote.controller.e2e-spec.ts b/src/quote/quote.controller.e2e-spec.ts index d2c7dd4..708e04b 100644 --- a/src/quote/quote.controller.e2e-spec.ts +++ b/src/quote/quote.controller.e2e-spec.ts @@ -1,18 +1,30 @@ import { AuthorRepositoryContract } from '@app/author/contracts/author-repository.contract'; import { QuoteRepositoryContract } from '@app/quote/contracts/quote-repository.contract'; +import { UserRepositoryContract } from '@app/user/contracts/user-repository.contract'; +import type { User } from '@app/user/entities/user.entity'; import { HttpStatus } from '@nestjs/common'; -import { authorFactory, quoteFactory } from '@test/factories'; +import { getAccessToken } from '@test/auth'; +import { authorFactory, quoteFactory, userFactory } from '@test/factories'; import { app, server } from '@test/server'; import * as request from 'supertest'; +let userRepository: UserRepositoryContract; let quoteRepository: QuoteRepositoryContract; let authorRepository: AuthorRepositoryContract; +let user: User; +let token: string; beforeAll(() => { + userRepository = app.get(UserRepositoryContract); quoteRepository = app.get(QuoteRepositoryContract); authorRepository = app.get(AuthorRepositoryContract); }); +beforeEach(async () => { + user = await userRepository.create(userFactory()); + token = await getAccessToken(app, { email: user.email }); +}); + describe('(GET) /quotes', () => { it('should be able to view quotes paginated', async () => { await quoteRepository.create(quoteFactory()); @@ -99,3 +111,13 @@ describe('(GET) /quotes/:uuid', () => { }); }); }); + +describe.skip('(GET) /quotes/:uuid/favorite', () => { + it('should be able to view a quote', async () => { + const quote = await quoteRepository.create(quoteFactory()); + + const response = await request(server).get(`/quotes/${quote.uuid}/favorite`).auth(token, { type: 'bearer' }).send(); + + expect(response.status).toBe(HttpStatus.OK); + }); +}); diff --git a/src/quote/quote.controller.ts b/src/quote/quote.controller.ts index 20be7b7..3019880 100644 --- a/src/quote/quote.controller.ts +++ b/src/quote/quote.controller.ts @@ -1,14 +1,19 @@ import { Public } from '@app/auth/decorators/public.decorator'; +import { UserDecorator } from '@app/auth/decorators/user.decorator'; import { QuotePaginatedQuery } from '@app/quote/dtos/quote-paginated-query'; +import { FavoriteQuoteUseCase } from '@app/quote/use-cases/favorite-quote.use-case'; import { GetOneQuoteUseCase } from '@app/quote/use-cases/get-one-quote.use-case'; import { ListQuotePaginatedUseCase } from '@app/quote/use-cases/list-quote-paginated.use-case'; -import { Controller, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; +import { User } from '@app/user/entities/user.entity'; +import { Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth } from '@nestjs/swagger'; @Controller('quotes') export class QuoteController { public constructor( private readonly listQuotePaginatedUseCase: ListQuotePaginatedUseCase, - private readonly getOneQuoteUseCase: GetOneQuoteUseCase + private readonly getOneQuoteUseCase: GetOneQuoteUseCase, + private readonly favoriteQuoteUseCase: FavoriteQuoteUseCase ) {} @Public() @@ -25,10 +30,10 @@ export class QuoteController { return this.getOneQuoteUseCase.handle({ uuid }); } - // @ApiBearerAuth() - // @Post(':uuid/favorite') - // @HttpCode(HttpStatus.OK) - // public async favorite(@Param('uuid') uuid: string) { - // // - // } + @ApiBearerAuth() + @Post(':uuid/favorite') + @HttpCode(HttpStatus.OK) + public async favorite(@Param('uuid') uuid: string, @UserDecorator() user: User) { + return this.favoriteQuoteUseCase.handle({ quoteUuid: uuid, userUuid: user.uuid }); + } } diff --git a/src/quote/quote.module.ts b/src/quote/quote.module.ts index fd7e655..94d0b7a 100644 --- a/src/quote/quote.module.ts +++ b/src/quote/quote.module.ts @@ -4,12 +4,14 @@ import { QuoteRepositoryContract } from '@app/quote/contracts/quote-repository.c import { QuoteController } from '@app/quote/quote.controller'; import { PrismaQuoteRepository } from '@app/quote/repositories/prisma-quote.repository'; import { QuoteService } from '@app/quote/services/quote.service'; +import { FavoriteQuoteUseCase } from '@app/quote/use-cases/favorite-quote.use-case'; import { GetOneQuoteUseCase } from '@app/quote/use-cases/get-one-quote.use-case'; import { ListQuotePaginatedUseCase } from '@app/quote/use-cases/list-quote-paginated.use-case'; +import { UserModule } from '@app/user/user.module'; import { Module } from '@nestjs/common'; @Module({ - imports: [PrismaModule, AuthorModule], + imports: [PrismaModule, AuthorModule, UserModule], controllers: [QuoteController], providers: [ { @@ -19,6 +21,7 @@ import { Module } from '@nestjs/common'; QuoteService, ListQuotePaginatedUseCase, GetOneQuoteUseCase, + FavoriteQuoteUseCase, ], exports: [QuoteRepositoryContract], }) diff --git a/src/quote/repositories/prisma-quote.repository.e2e-spec.ts b/src/quote/repositories/prisma-quote.repository.e2e-spec.ts index 2a411e2..b428f07 100644 --- a/src/quote/repositories/prisma-quote.repository.e2e-spec.ts +++ b/src/quote/repositories/prisma-quote.repository.e2e-spec.ts @@ -3,10 +3,34 @@ import { PrismaQuoteRepository } from '@app/quote/repositories/prisma-quote.repo import { faker } from '@faker-js/faker'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { quoteFactory } from '@test/factories'; +import { FavoritableType } from '@prisma/client'; +import { quoteFactory, userFactory } from '@test/factories'; import { prisma } from '@test/server'; import * as _ from 'lodash'; +const createQuotesFavorited = async (args: { userId: number; count: number }) => { + const { userId, count } = args; + + const quotes = await prisma.quote.createManyAndReturn({ + data: _.range(count).map(quoteFactory), + }); + + const promises = quotes.map((quote) => + prisma.quote.update({ + data: { + userOnFavoritable: { + create: { userId, favoritableType: FavoritableType.Quote }, + }, + }, + where: { uuid: quote.uuid }, + }) + ); + + await Promise.all(promises); + + return quotes; +}; + describe('PrismaQuoteRepository', () => { let quoteRepository: PrismaQuoteRepository; @@ -79,6 +103,50 @@ describe('PrismaQuoteRepository', () => { }); }); + describe.skip('findManyFavoritedByUser', () => { + it('should find quotes favorited by a specific user', async () => { + const user = await prisma.user.create({ + data: userFactory(), + }); + + await prisma.quote.createMany({ + data: _.range(5).map(quoteFactory), + }); + + const quotes = await createQuotesFavorited({ + userId: Number(user.id), + count: 5, + }); + + const result = await quoteRepository.findManyFavoritedByUser({ + where: { userId: Number(user.id) }, + }); + + const favoritedQuotesUuid = quotes.map((quote) => quote.uuid); + + expect(result).toHaveLength(5); + result.forEach((quote) => { + expect(favoritedQuotesUuid).toContain(quote.uuid); + }); + }); + + it('should return an empty array if the user has no favorited quotes', async () => { + const user = await prisma.user.create({ + data: userFactory(), + }); + + await prisma.quote.createMany({ + data: _.range(5).map(quoteFactory), + }); + + const result = await quoteRepository.findManyFavoritedByUser({ + where: { userId: Number(user.id) }, + }); + + expect(result).toEqual([]); + }); + }); + describe('findManyPaginated', () => { it('should find many quotes paginated', async () => { await prisma.quote.createMany({ diff --git a/src/quote/repositories/prisma-quote.repository.ts b/src/quote/repositories/prisma-quote.repository.ts index 5e4f7c8..70e5793 100644 --- a/src/quote/repositories/prisma-quote.repository.ts +++ b/src/quote/repositories/prisma-quote.repository.ts @@ -5,6 +5,7 @@ import type { QuoteRepositoryContract } from '@app/quote/contracts/quote-reposit import type { QuoteRepositoryCreateInput, QuoteRepositoryDeleteInput, + QuoteRepositoryFindManyFavoritedByUserInput, QuoteRepositoryFindManyInput, QuoteRepositoryFindManyPaginatedInput, QuoteRepositoryFindUniqueOrThrowInput, @@ -62,6 +63,10 @@ export class PrismaQuoteRepository implements QuoteRepositoryContract { return entities.map(prismaQuoteToQuoteAdapter); } + public findManyFavoritedByUser(input: QuoteRepositoryFindManyFavoritedByUserInput): Promise { + throw new Error('Method not implemented.'); + } + public async create(input: QuoteRepositoryCreateInput) { const entity = await this.prismaManager.getClient().quote.create({ data: input, diff --git a/src/quote/services/quote.service.ts b/src/quote/services/quote.service.ts index e6dcda8..54fcb6c 100644 --- a/src/quote/services/quote.service.ts +++ b/src/quote/services/quote.service.ts @@ -1,4 +1,8 @@ import { Injectable } from '@nestjs/common'; @Injectable() -export class QuoteService {} +export class QuoteService { + public favorite(args: { userId: number; quoteId: number }): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/quote/use-cases/favorite-quote.use-case.ts b/src/quote/use-cases/favorite-quote.use-case.ts new file mode 100644 index 0000000..ae98b4b --- /dev/null +++ b/src/quote/use-cases/favorite-quote.use-case.ts @@ -0,0 +1,33 @@ +import { QuoteRepositoryContract } from '@app/quote/contracts/quote-repository.contract'; +import type { FavoriteQuoteInput } from '@app/quote/dtos/favorite-quote-input'; +import { QuoteService } from '@app/quote/services/quote.service'; +import type { UseCaseHandler } from '@app/shared/interfaces'; +import { UserRepositoryContract } from '@app/user/contracts/user-repository.contract'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class FavoriteQuoteUseCase implements UseCaseHandler { + public constructor( + private readonly quoteRepository: QuoteRepositoryContract, + private readonly userRepository: UserRepositoryContract, + private readonly quoteService: QuoteService + ) {} + + public async handle(input: FavoriteQuoteInput): Promise { + const { quoteUuid, userUuid } = input; + + const user = await this.userRepository.findUniqueOrThrow({ + where: { + uuid: userUuid, + }, + }); + + const quote = await this.quoteRepository.findUniqueOrThrow({ + where: { + uuid: quoteUuid, + }, + }); + + return this.quoteService.favorite({ quoteId: quote.id, userId: user.id }); + } +}