From dc8a872ca1ff2339763f27582ecf6602446710b0 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Fri, 13 Feb 2026 16:23:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=8B=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EB=B3=B5=EC=8A=B5?= =?UTF-8?q?=20=EC=A3=BC=EA=B8=B0=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/service/ReviewService.java | 19 +++++ .../review/service/ReviewServiceTest.java | 85 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 28642e6..af7384a 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -1,8 +1,12 @@ package com.recyclestudy.review.service; import com.recyclestudy.common.BaseEntity; +import com.recyclestudy.cycle.domain.CycleOption; +import com.recyclestudy.cycle.domain.selection.CustomCycleSelection; import com.recyclestudy.cycle.domain.selection.CycleSelection; +import com.recyclestudy.cycle.repository.CycleOptionRepository; import com.recyclestudy.cycle.service.resolver.CycleSelectionResolverRegistry; +import com.recyclestudy.exception.NotFoundException; import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Member; import com.recyclestudy.member.repository.MemberRepository; @@ -33,6 +37,7 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final ReviewCycleRepository reviewCycleRepository; private final MemberRepository memberRepository; + private final CycleOptionRepository cycleOptionRepository; private final CycleSelectionResolverRegistry cycleSelectionResolverRegistry; private final NotificationHistoryRepository notificationHistoryRepository; private final Clock clock; @@ -46,6 +51,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final Review savedReview = reviewRepository.save(review); log.info("[REVIEW_SAVED] 복습 주제 저장 성공: reviewId={}", savedReview.getId()); + validateCycleSelectionOwnership(input.cycle(), member); final List scheduledAts = calculateScheduledAts(input.cycle(), member); final List reviewCycles = scheduledAts.stream() @@ -98,4 +104,17 @@ private void savePendingNotificationHistory(final List savedReviewC log.info("[NOTIFY_HIST_SAVED] 전송 현황 등록 성공: status={}, notificationHistoryId={}", NotificationStatus.PENDING, savedNotificationHistories.stream().map(BaseEntity::getId).toList()); } + + private void validateCycleSelectionOwnership(final CycleSelection cycleSelection, final Member member) { + if (!(cycleSelection instanceof CustomCycleSelection(Long id))) { + return; + } + + final CycleOption cycleOption = cycleOptionRepository.findById(id) + .orElseThrow(() -> new NotFoundException("존재하지 않는 복습 주기입니다")); + + if (!cycleOption.isOwner(member)) { + throw new NotFoundException("존재하지 않는 복습 주기입니다"); + } + } } diff --git a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java index 050a4f8..226e1ab 100644 --- a/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java +++ b/src/test/java/com/recyclestudy/review/service/ReviewServiceTest.java @@ -1,7 +1,11 @@ package com.recyclestudy.review.service; +import com.recyclestudy.cycle.domain.CycleOption; +import com.recyclestudy.cycle.domain.selection.CustomCycleSelection; import com.recyclestudy.cycle.domain.selection.DefaultCycleSelection; +import com.recyclestudy.cycle.repository.CycleOptionRepository; import com.recyclestudy.cycle.service.resolver.CycleSelectionResolverRegistry; +import com.recyclestudy.exception.NotFoundException; import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.DeviceIdentifier; import com.recyclestudy.member.domain.Email; @@ -41,6 +45,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -55,6 +60,9 @@ class ReviewServiceTest { @Mock MemberRepository memberRepository; + @Mock + CycleOptionRepository cycleOptionRepository; + @Mock CycleSelectionResolverRegistry cycleSelectionResolverRegistry; @@ -171,4 +179,81 @@ void saveReview_withPreferredNotificationTime() { .isEqualTo(now.plusDays(1).with(preferredTime)); }); } + + @Test + @DisplayName("사용자가 소유한 커스텀 주기로 리뷰를 저장한다") + void saveReview_withCustomCycle_owner() { + // given + final long cycleOptionId = 1L; + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final CustomCycleSelection cycleSelection = new CustomCycleSelection(cycleOptionId); + final ReviewSaveInput input = ReviewSaveInput.of(identifier, "https://test.com", cycleSelection); + + final Member member = Member.withoutId(Email.from("test@test.com")); + final Review review = Review.withoutId(member, input.url()); + final ReviewCycle cycle = ReviewCycle.withoutId(review, now.plusDays(1)); + final CycleOption cycleOption = mock(CycleOption.class); + final List durations = List.of(Duration.ofDays(1)); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + given(reviewRepository.save(any(Review.class))).willReturn(review); + given(cycleOptionRepository.findById(cycleOptionId)).willReturn(Optional.of(cycleOption)); + given(cycleOption.isOwner(member)).willReturn(true); + given(cycleSelectionResolverRegistry.resolve(cycleSelection)).willReturn(durations); + given(reviewCycleRepository.saveAll(anyList())).willReturn(List.of(cycle)); + + // when + reviewService.saveReview(input); + + // then + verify(cycleOptionRepository).findById(cycleOptionId); + verify(cycleOption).isOwner(member); + verify(cycleSelectionResolverRegistry).resolve(cycleSelection); + } + + @Test + @DisplayName("존재하지 않는 커스텀 주기일 경우 예외를 던진다") + void saveReview_withCustomCycle_notFoundCycleOption() { + // given + final long cycleOptionId = 999L; + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final CustomCycleSelection cycleSelection = new CustomCycleSelection(cycleOptionId); + final ReviewSaveInput input = ReviewSaveInput.of(identifier, "https://test.com", cycleSelection); + + final Member member = Member.withoutId(Email.from("test@test.com")); + final Review review = Review.withoutId(member, input.url()); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + given(reviewRepository.save(any(Review.class))).willReturn(review); + given(cycleOptionRepository.findById(cycleOptionId)).willReturn(Optional.empty()); + + // when + // then + assertThatThrownBy(() -> reviewService.saveReview(input)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인이 소유하지 않은 커스텀 주기일 경우 예외를 던진다") + void saveReview_withCustomCycle_notOwner() { + // given + final long cycleOptionId = 10L; + final DeviceIdentifier identifier = DeviceIdentifier.from("device-id"); + final CustomCycleSelection cycleSelection = new CustomCycleSelection(cycleOptionId); + final ReviewSaveInput input = ReviewSaveInput.of(identifier, "https://test.com", cycleSelection); + + final Member member = Member.withoutId(Email.from("test@test.com")); + final Review review = Review.withoutId(member, input.url()); + final CycleOption cycleOption = mock(CycleOption.class); + + given(memberRepository.findByIdentifier(identifier)).willReturn(Optional.of(member)); + given(reviewRepository.save(any(Review.class))).willReturn(review); + given(cycleOptionRepository.findById(cycleOptionId)).willReturn(Optional.of(cycleOption)); + given(cycleOption.isOwner(member)).willReturn(false); + + // when + // then + assertThatThrownBy(() -> reviewService.saveReview(input)) + .isInstanceOf(NotFoundException.class); + } }