Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main/java/com/recyclestudy/review/service/ReviewService.java
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Comment on lines 51 to 56
Copy link

Copilot AI Feb 13, 2026

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.

Suggested change
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);
validateCycleSelectionOwnership(input.cycle(), member);
final List<LocalDateTime> scheduledAts = calculateScheduledAts(input.cycle(), member);
final Review savedReview = reviewRepository.save(review);
log.info("[REVIEW_SAVED] 복습 주제 저장 성공: reviewId={}", savedReview.getId());

Copilot uses AI. Check for mistakes.
final List<ReviewCycle> reviewCycles = scheduledAts.stream()
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message inconsistency: The error message "존재하지 않는 복습 주기입니다" differs from the pattern used elsewhere in the codebase. In CycleOptionService (lines 66, 81, 97 in cycle/service/CycleOptionService.java) and CustomCycleSelectionResolver (line 27 in cycle/service/resolver/CustomCycleSelectionResolver.java), the message is "존재하지 않는 주기 옵션입니다." with a period. For consistency with the existing codebase patterns, use "존재하지 않는 주기 옵션입니다." instead.

Suggested change
.orElseThrow(() -> new NotFoundException("존재하지 않는 복습 주기입니다"));
if (!cycleOption.isOwner(member)) {
throw new NotFoundException("존재하지 않는 복습 주기입니다");
.orElseThrow(() -> new NotFoundException("존재하지 않는 주기 옵션입니다."));
if (!cycleOption.isOwner(member)) {
throw new NotFoundException("존재하지 않는 주기 옵션입니다.");

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 13, 2026

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.

Suggested change
throw new NotFoundException("존재하지 않는 복습 주기입니다");
throw new NotFoundException("존재하지 않는 주기 옵션입니다.");

Copilot uses AI. Check for mistakes.
}
}
}
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;
Expand Down Expand Up @@ -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)
Expand All @@ -55,6 +60,9 @@ class ReviewServiceTest {
@Mock
MemberRepository memberRepository;

@Mock
CycleOptionRepository cycleOptionRepository;

@Mock
CycleSelectionResolverRegistry cycleSelectionResolverRegistry;

Expand Down Expand Up @@ -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);
}

@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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test demonstrates the bug: This test mocks reviewRepository.save to succeed (line 227) before the validation failure occurs. Because the actual implementation saves the review before validating ownership (lines 50-54 in ReviewService), this test will leave an orphaned Review in the database when run against a real database. Once the bug in ReviewService is fixed to validate before saving, this mock setup should be removed since reviewRepository.save should never be called when validation fails.

Copilot uses AI. Check for mistakes.

// 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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test demonstrates the bug: Similar to the notFoundCycleOption test, this test mocks reviewRepository.save to succeed (line 250) before the validation failure. Once the bug in ReviewService is fixed to validate ownership before saving the review, this mock setup should be removed since reviewRepository.save should never be called when ownership validation fails.

Copilot uses AI. Check for mistakes.
given(cycleOption.isOwner(member)).willReturn(false);

// when
// then
assertThatThrownBy(() -> reviewService.saveReview(input))
.isInstanceOf(NotFoundException.class);
}
}
Loading