From 37262e0e2a7599d8ca099b8601c641a4d0faef21 Mon Sep 17 00:00:00 2001 From: skgndi12 Date: Thu, 8 Feb 2024 18:51:07 +0900 Subject: [PATCH] feat: implement get reviews service method --- .../core/services/review/review.service.ts | 76 ++++++- api/src/core/services/review/types.ts | 30 +++ .../services/review/review.service.test.ts | 197 +++++++++++++++++- 3 files changed, 300 insertions(+), 3 deletions(-) diff --git a/api/src/core/services/review/review.service.ts b/api/src/core/services/review/review.service.ts index c8d59d57..5c8c9cb8 100644 --- a/api/src/core/services/review/review.service.ts +++ b/api/src/core/services/review/review.service.ts @@ -1,10 +1,11 @@ import { AccessLevel } from '@prisma/client'; -import { Review } from '@src/core/entities/review.entity'; +import { Reply, Review } from '@src/core/entities/review.entity'; import { User } from '@src/core/entities/user.entity'; import { ReplyRepository } from '@src/core/ports/reply.repository'; import { CreateReviewParams, + FindReviewsParams, ReviewRepository } from '@src/core/ports/review.repository'; import { @@ -15,7 +16,9 @@ import { UserRepository } from '@src/core/ports/user.repository'; import { CreateReviewDto, CreateReviewResponse, - GetReviewResponse + GetReviewResponse, + GetReviewsDto, + GetReviewsResponse } from '@src/core/services/review/types'; import { AccessLevelEnum } from '@src/core/types'; import { AppErrorCode, CustomError } from '@src/error/errors'; @@ -92,4 +95,73 @@ export class ReviewService { review }; }; + + public getReviews = async ( + dto: GetReviewsDto + ): Promise => { + const sortBy = dto.sortBy ?? 'createdAt'; + const direction = dto.direction ?? 'desc'; + const pageOffset = dto.pageOffset ?? 1; + const pageSize = dto.pageSize ?? 10; + + if (!(pageSize >= 1 && pageSize <= 100)) { + throw new CustomError({ + code: AppErrorCode.BAD_REQUEST, + message: 'page size should be between 1 and 100', + context: { pageSize } + }); + } + + const [users, reviews, reviewCount] = await this.txManager.runInTransaction( + async ( + txClient: TransactionClient + ): Promise<[User[], Review[], number]> => { + const params: FindReviewsParams = { + nickname: dto.nickname, + title: dto.title, + movieName: dto.movieName, + sortBy, + direction, + pageOffset, + pageSize + }; + const { reviews, reviewCount } = + await this.reviewRepository.findManyAndCount(params, txClient); + + const userIds = this.extractUserIds(reviews); + const users = await this.userRepository.findByIds(userIds, txClient); + + return [users, reviews, reviewCount]; + }, + IsolationLevel.READ_COMMITTED + ); + + const additionalPageCount = reviewCount % pageSize !== 0 ? 1 : 0; + const totalPageCount = + Math.floor(reviewCount / pageSize) + additionalPageCount; + const pagination = { + sortBy, + direction, + pageOffset, + pageSize, + totalEntryCount: reviewCount, + totalPageCount + }; + + return { + users, + reviews, + pagination + }; + }; + + private extractUserIds = (entries: Review[] | Reply[]): string[] => { + const userIds: string[] = []; + entries.forEach((entry) => { + userIds.push(entry.userId); + }); + const uniqueUserIds = [...new Set(userIds)]; + + return uniqueUserIds; + }; } diff --git a/api/src/core/services/review/types.ts b/api/src/core/services/review/types.ts index 87a3edc6..079fbce3 100644 --- a/api/src/core/services/review/types.ts +++ b/api/src/core/services/review/types.ts @@ -6,6 +6,15 @@ export type SortBy = 'createdAt' | 'movieName'; export type Direction = 'asc' | 'desc'; +export interface ReviewsPaginationResponse { + sortBy: SortBy; + direction: Direction; + pageOffset: number; + pageSize: number; + totalEntryCount: number; + totalPageCount: number; +} + export interface CreateReviewResponse { user: User; review: Review; @@ -16,9 +25,30 @@ export interface GetReviewResponse { review: Review; } +export interface GetReviewsResponse { + users: User[]; + reviews: Review[]; + pagination: ReviewsPaginationResponse; +} + export interface CreateReviewDto { requesterIdToken: AppIdToken; title: string; movieName: string; content: string; } + +export interface GetReviewsDto { + // filter + nickname?: string; + title?: string; + movieName?: string; + + // sort + sortBy?: SortBy; + direction?: Direction; + + // pagination + pageOffset?: number; + pageSize?: number; +} diff --git a/api/test/core/services/review/review.service.test.ts b/api/test/core/services/review/review.service.test.ts index f3816d29..509f7ba6 100644 --- a/api/test/core/services/review/review.service.test.ts +++ b/api/test/core/services/review/review.service.test.ts @@ -11,8 +11,12 @@ import { ReviewRepository } from '@src/core/ports/review.repository'; import { TransactionManager } from '@src/core/ports/transaction.manager'; import { UserRepository } from '@src/core/ports/user.repository'; import { ReviewService } from '@src/core/services/review/review.service'; -import { CreateReviewDto } from '@src/core/services/review/types'; +import { + CreateReviewDto, + GetReviewsDto +} from '@src/core/services/review/types'; import { AccessLevelEnum, IdpEnum } from '@src/core/types'; +import { AppErrorCode, CustomError } from '@src/error/errors'; import { PrismaTransactionManager } from '@src/infrastructure/prisma/prisma.transaction.manager'; import { ExtendedPrismaClient } from '@src/infrastructure/prisma/types'; import { PostgresqlReviewRepository } from '@src/infrastructure/repositories/postgresql/review.repository'; @@ -25,6 +29,12 @@ jest.mock('@root/test/infrastructure/prisma/test.prisma.client', () => ({ const prismaMock = extendedPrisma as DeepMockProxy; +function calculateTotalPageCount(count: number, pageSize?: number) { + const givenPageSize = pageSize ?? 10; + const additionalPageCount = count % givenPageSize !== 0 ? 1 : 0; + return Math.floor(count / givenPageSize) + additionalPageCount; +} + describe('Test review service', () => { let userRepository: UserRepository; let reviewRepository: ReviewRepository; @@ -240,4 +250,189 @@ describe('Test review service', () => { expect(reviewFindByIdArgs).toEqual(reviewId); }); }); + + describe('Test get reviews', () => { + const userId = 'randomId'; + const nickname = 'randomNickname'; + const tag = '#TAGG'; + const idp = new IdpEnum(Idp.GOOGLE); + const email = 'user1@gmail.com'; + const accessLevel = new AccessLevelEnum(AccessLevel.USER); + const title = 'randomTitle'; + const movieName = 'randomMovie'; + const content = 'randomContent'; + const currentDate = new Date(); + const users: User[] = []; + const reviews: Review[] = []; + const reviewCount = 10; + + const userFindByIds = jest.fn(() => Promise.resolve(users)) as jest.Mock; + const reviewFindManyCount = jest.fn(() => + Promise.resolve({ reviews, reviewCount }) + ) as jest.Mock; + + beforeAll(() => { + users.push( + new User( + userId, + nickname, + tag, + idp, + email, + accessLevel, + currentDate, + currentDate + ) + ); + + for (let i = 1; i <= reviewCount; i++) { + reviews.push( + new Review( + i, + userId, + title, + movieName, + content, + 0, + currentDate, + currentDate + ) + ); + } + + prismaMock.$transaction.mockImplementation((callback) => + callback(prismaMock) + ); + userRepository = new PostgresqlUserRepository(prismaMock); + reviewRepository = new PostgresqlReviewRepository(prismaMock); + txManager = new PrismaTransactionManager(prismaMock); + userRepository.findByIds = userFindByIds; + reviewRepository.findManyAndCount = reviewFindManyCount; + }); + + it('should success when page size is not provided', async () => { + const givenDto: GetReviewsDto = { + nickname, + title, + movieName + }; + const actualResult = await new ReviewService( + userRepository, + reviewRepository, + replyRepository, + txManager + ).getReviews(givenDto); + const totalPageCount = calculateTotalPageCount(reviewCount); + + expect(actualResult.pagination).toEqual({ + sortBy: 'createdAt', + direction: 'desc', + pageOffset: 1, + pageSize: 10, + totalEntryCount: reviewCount, + totalPageCount + }); + expect(JSON.stringify(actualResult.users)).toEqual(JSON.stringify(users)); + for (let i = 0; i <= 10; i++) { + expect(JSON.stringify(actualResult.reviews[i])).toEqual( + JSON.stringify(reviews[i]) + ); + } + + expect(userRepository.findByIds).toBeCalledTimes(1); + const userFindByIdArgs = userFindByIds.mock.calls[0][0]; + expect(userFindByIdArgs).toEqual([userId]); + + expect(reviewRepository.findManyAndCount).toBeCalledTimes(1); + const reviewFindManyCountArgs = reviewFindManyCount.mock.calls[0][0]; + expect(reviewFindManyCountArgs).toEqual( + expect.objectContaining({ + nickname: givenDto.nickname, + title: givenDto.title, + movieName: givenDto.movieName, + sortBy: 'createdAt', + direction: 'desc', + pageOffset: 1, + pageSize: 10 + }) + ); + }); + + it('should success when a page size is in the range of 1 to 100', async () => { + const givenPageSize = 5; + const givenDto: GetReviewsDto = { + nickname, + title, + movieName, + pageSize: givenPageSize + }; + const actualResult = await new ReviewService( + userRepository, + reviewRepository, + replyRepository, + txManager + ).getReviews(givenDto); + const totalPageCount = calculateTotalPageCount( + reviewCount, + givenPageSize + ); + + expect(actualResult.pagination).toEqual({ + sortBy: 'createdAt', + direction: 'desc', + pageOffset: 1, + pageSize: givenPageSize, + totalEntryCount: reviewCount, + totalPageCount + }); + expect(JSON.stringify(actualResult.users)).toEqual(JSON.stringify(users)); + for (let i = 0; i <= givenPageSize; i++) { + expect(JSON.stringify(actualResult.reviews[i])).toEqual( + JSON.stringify(reviews[i]) + ); + } + + expect(userRepository.findByIds).toBeCalledTimes(1); + const userFindByIdArgs = userFindByIds.mock.calls[0][0]; + expect(userFindByIdArgs).toEqual([userId]); + + expect(reviewRepository.findManyAndCount).toBeCalledTimes(1); + const reviewFindManyCountArgs = reviewFindManyCount.mock.calls[0][0]; + expect(reviewFindManyCountArgs).toEqual( + expect.objectContaining({ + nickname: givenDto.nickname, + title: givenDto.title, + movieName: givenDto.movieName, + sortBy: 'createdAt', + direction: 'desc', + pageOffset: 1, + pageSize: givenPageSize + }) + ); + }); + + it('should fail when page size exceeds 100', async () => { + const givenPageSize = 101; + const givenDto: GetReviewsDto = { + nickname, + title, + movieName, + pageSize: givenPageSize + }; + + try { + await new ReviewService( + userRepository, + reviewRepository, + replyRepository, + txManager + ).getReviews(givenDto); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CustomError); + expect(error).toHaveProperty('code', AppErrorCode.BAD_REQUEST); + } + + expect(reviewRepository.findManyAndCount).toBeCalledTimes(0); + }); + }); });