diff --git a/api/src/core/services/review/review.service.ts b/api/src/core/services/review/review.service.ts index db0710ba..80114c20 100644 --- a/api/src/core/services/review/review.service.ts +++ b/api/src/core/services/review/review.service.ts @@ -24,6 +24,7 @@ import { CreateReplyResponse, CreateReviewDto, CreateReviewResponse, + DeleteReplyDto, DeleteReviewDto, GetRepliesDto, GetRepliesResponse, @@ -396,6 +397,39 @@ export class ReviewService { }; }; + public deleteReply = async (dto: DeleteReplyDto): Promise => { + return await this.txManager.runInTransaction( + async (txClient: TransactionClient): Promise => { + const replyToDelete = await this.replyRepository.findById( + dto.replyId, + txClient + ); + const userReplying = await this.userRepository.findById( + replyToDelete.userId, + txClient + ); + + if ( + !dto.requesterIdToken.isAccessLevelAndUserIdAuthorized( + new AccessLevelEnum(AccessLevel.DEVELOPER), + userReplying.id + ) + ) { + throw new CustomError({ + code: AppErrorCode.PERMISSIION_DENIED, + message: 'insufficient access level to delete reply', + context: { dto, reply: replyToDelete, user: userReplying } + }); + } + + await this.replyRepository.deleteById(dto.replyId); + + return null; + }, + IsolationLevel.READ_COMMITTED + ); + }; + private extractUserIds = (entries: Review[] | Reply[]): string[] => { const userIds: string[] = []; entries.forEach((entry) => { diff --git a/api/src/core/services/review/types.ts b/api/src/core/services/review/types.ts index ac81592b..850473f2 100644 --- a/api/src/core/services/review/types.ts +++ b/api/src/core/services/review/types.ts @@ -118,3 +118,9 @@ export interface UpdateReplyDto { replyId: number; content: string; } + +export interface DeleteReplyDto { + requesterIdToken: AppIdToken; + reviewId: number; + replyId: number; +} diff --git a/api/test/core/services/review/review.service.test.ts b/api/test/core/services/review/review.service.test.ts index 7236f5b5..6a57f359 100644 --- a/api/test/core/services/review/review.service.test.ts +++ b/api/test/core/services/review/review.service.test.ts @@ -14,6 +14,7 @@ import { ReviewService } from '@src/core/services/review/review.service'; import { CreateReplyDto, CreateReviewDto, + DeleteReplyDto, DeleteReviewDto, GetRepliesDto, GetReviewsDto, @@ -1127,4 +1128,126 @@ describe('Test review service', () => { expect(replyRepository.update).toBeCalledTimes(0); }); }); + + describe('Test delete reply', () => { + 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 requesterIdToken = new AppIdToken( + userId, + nickname, + tag, + idp, + email, + accessLevel + ); + const reviewId = 0; + const replyId = 1; + const content = 'randomContent'; + const createdAt = new Date(); + + const userFound = new User( + userId, + nickname, + tag, + idp, + email, + accessLevel, + createdAt, + createdAt + ); + const replyFound = new Reply( + replyId, + reviewId, + userId, + content, + createdAt, + createdAt + ); + + const replyFindById = jest.fn(() => + Promise.resolve(replyFound) + ) as jest.Mock; + const userFindById = jest.fn(() => Promise.resolve(userFound)) as jest.Mock; + const replyDeleteById = jest.fn(() => Promise.resolve()) as jest.Mock; + + beforeAll(() => { + prismaMock.$transaction.mockImplementation((callback) => + callback(prismaMock) + ); + userRepository = new PostgresqlUserRepository(prismaMock); + reviewRepository = new PostgresqlReviewRepository(prismaMock); + replyRepository = new PostgresqlReplyRepository(prismaMock); + txManager = new PrismaTransactionManager(prismaMock); + userRepository.findById = userFindById; + replyRepository.findById = replyFindById; + replyRepository.deleteById = replyDeleteById; + }); + + it('should success when valid', async () => { + const givenDto: DeleteReplyDto = { + requesterIdToken, + reviewId, + replyId + }; + await new ReviewService( + userRepository, + reviewRepository, + replyRepository, + txManager + ).deleteReply(givenDto); + + expect(replyRepository.findById).toBeCalledTimes(1); + const replyFindByIdArgs = replyFindById.mock.calls[0][0]; + expect(replyFindByIdArgs).toEqual(givenDto.replyId); + + expect(userRepository.findById).toBeCalledTimes(1); + const userFindByIdArgs = userFindById.mock.calls[0][0]; + expect(userFindByIdArgs).toEqual(replyFound.userId); + + expect(replyRepository.deleteById).toBeCalledTimes(1); + const replyDeleteByIdArgs = replyDeleteById.mock.calls[0][0]; + expect(replyDeleteByIdArgs).toEqual(givenDto.replyId); + }); + + it('should fail when access level and user authorization are invalid', async () => { + const givenRequesterIdToken = new AppIdToken( + 'anotherRandomId', + 'anotherNickname', + '#GGAT', + new IdpEnum(Idp.GOOGLE), + 'user100@gmail.com', + new AccessLevelEnum(AccessLevel.USER) + ); + const givenDto: DeleteReplyDto = { + requesterIdToken: givenRequesterIdToken, + reviewId, + replyId + }; + try { + await new ReviewService( + userRepository, + reviewRepository, + replyRepository, + txManager + ).deleteReply(givenDto); + } catch (error: unknown) { + expect(error).toBeInstanceOf(CustomError); + expect(error).toHaveProperty('code', AppErrorCode.PERMISSIION_DENIED); + } + + expect(replyRepository.findById).toBeCalledTimes(1); + const replyFindByIdArgs = replyFindById.mock.calls[0][0]; + expect(replyFindByIdArgs).toEqual(givenDto.replyId); + + expect(userRepository.findById).toBeCalledTimes(1); + const userFindByIdArgs = userFindById.mock.calls[0][0]; + expect(userFindByIdArgs).toEqual(replyFound.userId); + + expect(replyRepository.deleteById).toBeCalledTimes(0); + }); + }); });