-
Notifications
You must be signed in to change notification settings - Fork 0
커스텀 복습 주기 소유권 검증 누락 수정 #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<LocalDateTime> scheduledAts = calculateScheduledAts(input.cycle(), member); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| final List<ReviewCycle> reviewCycles = scheduledAts.stream() | ||||||||||||||||||||||
|
|
@@ -98,4 +104,17 @@ private void savePendingNotificationHistory(final List<ReviewCycle> 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("존재하지 않는 복습 주기입니다"); | ||||||||||||||||||||||
|
Comment on lines
+114
to
+117
|
||||||||||||||||||||||
| .orElseThrow(() -> new NotFoundException("존재하지 않는 복습 주기입니다")); | |
| if (!cycleOption.isOwner(member)) { | |
| throw new NotFoundException("존재하지 않는 복습 주기입니다"); | |
| .orElseThrow(() -> new NotFoundException("존재하지 않는 주기 옵션입니다.")); | |
| if (!cycleOption.isOwner(member)) { | |
| throw new NotFoundException("존재하지 않는 주기 옵션입니다."); |
Copilot
AI
Feb 13, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error message inconsistency: Same as above - use "존재하지 않는 주기 옵션입니다." (with period) to match the pattern in CycleOptionService.validateOwnership (line 97 in cycle/service/CycleOptionService.java) which throws NotFoundException with this exact message when ownership validation fails.
| throw new NotFoundException("존재하지 않는 복습 주기입니다"); | |
| throw new NotFoundException("존재하지 않는 주기 옵션입니다."); |
jhan0121 marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Duration> 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); | ||
jhan0121 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @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()); | ||
|
Comment on lines
+226
to
+228
|
||
|
|
||
| // 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)); | ||
|
Comment on lines
+249
to
+251
|
||
| given(cycleOption.isOwner(member)).willReturn(false); | ||
|
|
||
| // when | ||
| // then | ||
| assertThatThrownBy(() -> reviewService.saveReview(input)) | ||
| .isInstanceOf(NotFoundException.class); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical ordering issue: The review is saved to the database (line 51) before ownership validation occurs (line 54). If the validation fails, an orphaned Review entity will remain in the database without any associated ReviewCycles or NotificationHistory. The validation should be performed before persisting the Review entity to prevent data inconsistency.