diff --git a/src/main/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepository.java index c06e8cf2d..b29bac129 100644 --- a/src/main/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepository.java @@ -21,12 +21,14 @@ public interface UserSectionProgressJpaRepository extends JpaRepository findRankingsWithMyRank(@Param("targetUserId") Long targetUserId); } - diff --git a/src/main/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerService.java b/src/main/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerService.java index 2422134f8..5a4d1833b 100644 --- a/src/main/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerService.java +++ b/src/main/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerService.java @@ -87,9 +87,11 @@ public SubmitQuestionV2AnswerData.Result execute(SubmitQuestionV2AnswerData.Comm .findByUserIdAndChapterId(actor.id(), chapter.getId()); UserQuestionHistoryV2 history; + boolean wasClearedBefore = false; if (historyOpt.isPresent()) { history = historyOpt.get(); + wasClearedBefore = history.isCleared(); retryFirstQuestion(history, question); @@ -118,7 +120,7 @@ public SubmitQuestionV2AnswerData.Result execute(SubmitQuestionV2AnswerData.Comm Long nextChapterId = null; Integer nextChapterOrderIndex = null; - if (isChapterCleared) { + if (isChapterCleared && !wasClearedBefore) { // progress가 없고 첫 번째 챕터인 경우 새로 생성 if (progress == null && chapter.getOrderIndex() == 0) { progress = UserSectionProgress.start(actor.id(), chapter.getSectionId(), chapter.getId()); diff --git a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java index 2a6363ec5..5bbae3a80 100644 --- a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java @@ -6,6 +6,8 @@ import com.process.clash.adapter.persistence.roadmap.chapter.ChapterJpaMapper; import com.process.clash.adapter.persistence.roadmap.v2.chapter.ChapterV2JpaMapper; import com.process.clash.adapter.persistence.roadmap.v2.choice.ChoiceV2JpaMapper; +import com.process.clash.adapter.persistence.roadmap.v2.questionhistory.UserQuestionHistoryV2JpaEntity; +import com.process.clash.adapter.persistence.roadmap.v2.questionhistory.UserQuestionHistoryV2JpaMapper; import com.process.clash.adapter.persistence.roadmap.v2.question.QuestionV2JpaMapper; import com.process.clash.adapter.persistence.roadmap.choice.ChoiceJpaMapper; import com.process.clash.adapter.persistence.roadmap.keypoint.SectionKeyPointJpaMapper; @@ -25,6 +27,7 @@ import com.process.clash.infrastructure.config.JpaAuditingConfig; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -36,6 +39,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.stream.IntStream; @DataJpaTest @ActiveProfiles("test") @@ -52,6 +56,7 @@ ChapterV2JpaMapper.class, QuestionV2JpaMapper.class, ChoiceV2JpaMapper.class, + UserQuestionHistoryV2JpaMapper.class, SectionJpaMapper.class }) class UserSectionProgressJpaRepositoryTest { @@ -70,6 +75,10 @@ class UserSectionProgressJpaRepositoryTest { UserJpaEntity noSectionProgressUserJpaEntity; UserJpaEntity sectionProgressUserJpaEntity; + ChapterV2JpaEntity chapterJpaEntity; + ChapterV2JpaEntity secondChapterJpaEntity; + ChapterV2JpaEntity thirdChapterJpaEntity; + SectionJpaEntity sectionJpaEntity; @BeforeEach void beforeEach() { @@ -80,17 +89,25 @@ void beforeEach() { em.persist(categoryJpaEntity); // 2. Section 저장 (Category 참조) - SectionJpaEntity sectionJpaEntity = new SectionJpaEntity( + sectionJpaEntity = new SectionJpaEntity( null, Major.SERVER, "테스트 섹션", "테스트 설명", categoryJpaEntity, 1, new ArrayList<>(), new ArrayList<>(), new HashSet<>(), now, now ); em.persist(sectionJpaEntity); // 3. Chapter 저장 (Section 참조) - ChapterV2JpaEntity chapterJpaEntity = new ChapterV2JpaEntity( - null, sectionJpaEntity, "테스트 챕터", "테스트 챕터 설명", 1, null, new ArrayList<>(), now, now + chapterJpaEntity = new ChapterV2JpaEntity( + null, sectionJpaEntity, "테스트 챕터1", "테스트 챕터 설명", 1, null, new ArrayList<>(), now, now ); em.persist(chapterJpaEntity); + secondChapterJpaEntity = new ChapterV2JpaEntity( + null, sectionJpaEntity, "테스트 챕터2", "테스트 챕터 설명", 2, null, new ArrayList<>(), now, now + ); + em.persist(secondChapterJpaEntity); + thirdChapterJpaEntity = new ChapterV2JpaEntity( + null, sectionJpaEntity, "테스트 챕터3", "테스트 챕터 설명", 3, null, new ArrayList<>(), now, now + ); + em.persist(thirdChapterJpaEntity); // 4. User 저장 User noSectionProgressUser = new User(null, now, now, "userA", "userA@gmail.com", "유저A", "password", @@ -119,13 +136,180 @@ void beforeEach() { } @Test + @DisplayName("실제 클리어 히스토리만 랭킹 점수로 집계된다") void findRankingsWithMyRank() { + persistHistory(sectionProgressUserJpaEntity, chapterJpaEntity, true); + em.flush(); + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(noSectionProgressUserJpaEntity.getId()); Assertions.assertThat(rankingsWithMyRank).hasSize(2); Object[] firstRecord = rankingsWithMyRank.get(0); Assertions.assertThat(firstRecord).hasSize(7); + Assertions.assertThat(actualClearedCount(firstRecord)).isEqualTo(1L); Assertions.assertThat(firstRecord[5]).isEqualTo(RankTier.NONE.name()); Assertions.assertThat(firstRecord[6]).isEqualTo(ExpTier.UNRANKED.name()); } + + @Test + @DisplayName("user_section_progress.completedChapters 값은 랭킹 점수에 영향을 주지 않는다") + void findRankingsWithMyRank_ignoresUserSectionProgressCompletedChapters() { + UserSectionProgress inflatedProgress = new UserSectionProgress( + null, + noSectionProgressUserJpaEntity.getId(), + sectionJpaEntity.getId(), + chapterJpaEntity.getId(), + 999, + false + ); + UserSectionProgressJpaEntity inflatedProgressJpaEntity = userSectionProgressJpaMapper.toJpaEntity( + inflatedProgress, noSectionProgressUserJpaEntity, sectionJpaEntity, chapterJpaEntity + ); + em.persist(inflatedProgressJpaEntity); + em.flush(); + + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(noSectionProgressUserJpaEntity.getId()); + + Object[] targetRecord = rankingsWithMyRank.stream() + .filter(record -> record[0].equals(noSectionProgressUserJpaEntity.getId())) + .findFirst() + .orElseThrow(); + + Assertions.assertThat(actualClearedCount(targetRecord)).isEqualTo(0L); + } + + @Test + @DisplayName("isCleared=false 히스토리는 랭킹 점수에 포함되지 않는다") + void findRankingsWithMyRank_doesNotCountUnclearedHistory() { + persistHistory(sectionProgressUserJpaEntity, chapterJpaEntity, false); + em.flush(); + + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(sectionProgressUserJpaEntity.getId()); + + Object[] targetRecord = rankingsWithMyRank.stream() + .filter(record -> record[0].equals(sectionProgressUserJpaEntity.getId())) + .findFirst() + .orElseThrow(); + + Assertions.assertThat(actualClearedCount(targetRecord)).isEqualTo(0L); + } + + @Test + @DisplayName("여러 유저가 섞이면 실제 클리어 수 순으로 정렬된다") + void findRankingsWithMyRank_ordersByActualClearedCount() { + UserJpaEntity thirdUser = persistUser("userC", "유저C"); + + persistHistory(sectionProgressUserJpaEntity, chapterJpaEntity, true); + persistHistory(thirdUser, chapterJpaEntity, true); + persistHistory(thirdUser, secondChapterJpaEntity, true); + em.flush(); + + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(noSectionProgressUserJpaEntity.getId()); + + Assertions.assertThat(recordUserId(rankingsWithMyRank.get(0))).isEqualTo(thirdUser.getId()); + Assertions.assertThat(actualClearedCount(rankingsWithMyRank.get(0))).isEqualTo(2L); + Assertions.assertThat(recordUserId(rankingsWithMyRank.get(1))).isEqualTo(sectionProgressUserJpaEntity.getId()); + Assertions.assertThat(actualClearedCount(rankingsWithMyRank.get(1))).isEqualTo(1L); + } + + @Test + @DisplayName("top20 밖 유저도 targetUserId로 조회하면 결과에 포함된다") + void findRankingsWithMyRank_includesTargetUserOutsideTop20() { + List chapters = new ArrayList<>(); + chapters.add(chapterJpaEntity); + chapters.add(secondChapterJpaEntity); + chapters.add(thirdChapterJpaEntity); + + IntStream.rangeClosed(4, 21).forEach(index -> chapters.add(persistAdditionalChapter(index))); + + List rankedUsers = new ArrayList<>(); + for (int score = 21; score >= 1; score--) { + UserJpaEntity user = persistUser("ranked" + score, "랭커" + score); + rankedUsers.add(user); + for (int chapterIndex = 0; chapterIndex < score; chapterIndex++) { + persistHistory(user, chapters.get(chapterIndex), true); + } + } + em.flush(); + + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(noSectionProgressUserJpaEntity.getId()); + + Assertions.assertThat(rankingsWithMyRank).hasSize(21); + Assertions.assertThat(rankingsWithMyRank.stream() + .anyMatch(record -> recordUserId(record).equals(noSectionProgressUserJpaEntity.getId()))).isTrue(); + + Object[] targetRecord = rankingsWithMyRank.stream() + .filter(record -> recordUserId(record).equals(noSectionProgressUserJpaEntity.getId())) + .findFirst() + .orElseThrow(); + + Assertions.assertThat(rankValue(targetRecord)).isEqualTo(22); + } + + @Test + @DisplayName("동점 유저는 동일한 rank 값을 가진다") + void findRankingsWithMyRank_keepsSameRankWhenTied() { + UserJpaEntity thirdUser = persistUser("userTie", "유저동점"); + + persistHistory(sectionProgressUserJpaEntity, chapterJpaEntity, true); + persistHistory(thirdUser, chapterJpaEntity, true); + em.flush(); + + List rankingsWithMyRank = userSectionProgressJpaRepository.findRankingsWithMyRank(noSectionProgressUserJpaEntity.getId()); + + Object[] secondUserRecord = rankingsWithMyRank.stream() + .filter(record -> recordUserId(record).equals(sectionProgressUserJpaEntity.getId())) + .findFirst() + .orElseThrow(); + Object[] thirdUserRecord = rankingsWithMyRank.stream() + .filter(record -> recordUserId(record).equals(thirdUser.getId())) + .findFirst() + .orElseThrow(); + + Assertions.assertThat(rankValue(secondUserRecord)).isEqualTo(1); + Assertions.assertThat(rankValue(thirdUserRecord)).isEqualTo(1); + } + + private UserJpaEntity persistUser(String username, String name) { + Instant now = Instant.now(); + User user = new User(null, now, now, username, username + "@gmail.com", name, "password", + Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED); + UserJpaEntity entity = userJpaMapper.toJpaEntity(user); + em.persist(entity); + return entity; + } + + private ChapterV2JpaEntity persistAdditionalChapter(int orderIndex) { + ChapterV2JpaEntity chapter = new ChapterV2JpaEntity( + null, sectionJpaEntity, "테스트 챕터" + orderIndex, "테스트 챕터 설명", orderIndex, null, new ArrayList<>(), Instant.now(), Instant.now() + ); + em.persist(chapter); + return chapter; + } + + private void persistHistory(UserJpaEntity user, ChapterV2JpaEntity chapter, boolean isCleared) { + em.persist(new UserQuestionHistoryV2JpaEntity( + null, + user, + chapter, + isCleared, + isCleared ? 1 : 0, + 1, + isCleared ? 1 : 0, + null, + null + )); + } + + private Long recordUserId(Object[] record) { + return ((Number) record[0]).longValue(); + } + + private long actualClearedCount(Object[] record) { + return ((Number) record[3]).longValue(); + } + + private int rankValue(Object[] record) { + return ((Number) record[4]).intValue(); + } } diff --git a/src/test/java/com/process/clash/adapter/web/roadmap/v2/section/controller/SectionV2ControllerTest.java b/src/test/java/com/process/clash/adapter/web/roadmap/v2/section/controller/SectionV2ControllerTest.java index 69536c2e0..a5970a466 100644 --- a/src/test/java/com/process/clash/adapter/web/roadmap/v2/section/controller/SectionV2ControllerTest.java +++ b/src/test/java/com/process/clash/adapter/web/roadmap/v2/section/controller/SectionV2ControllerTest.java @@ -16,6 +16,7 @@ import com.process.clash.application.roadmap.v2.section.port.in.GetSectionV2ListUseCase; import com.process.clash.application.roadmap.v2.section.port.in.GetSectionV2PreviewUseCase; import java.util.List; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -79,6 +80,31 @@ void getSectionDetails_returnsCompletedField() throws Exception { verify(getSectionV2DetailsUseCase).execute(any(GetSectionV2DetailsData.Command.class)); } + @Test + @DisplayName("GET /api/v2/sections/{sectionId}/details 는 완료 여부와 무관하게 completed 필드를 항상 포함한다") + void getSectionDetails_alwaysIncludesCompletedField() throws Exception { + when(getSectionV2DetailsUseCase.execute(any())) + .thenReturn(new GetSectionV2DetailsData.Result( + 1L, + "스프링 입문", + false, + 3, + null, + null, + List.of() + )); + + mockMvc.perform(get("/api/v2/sections/1/details")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sectionId").value(1)) + .andExpect(jsonPath("$.data.completed").value(false)) + .andExpect(jsonPath("$.data.currentChapterId").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.currentOrderIndex").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.totalChapters").value(3)); + + verify(getSectionV2DetailsUseCase).execute(any(GetSectionV2DetailsData.Command.class)); + } + private HandlerMethodArgumentResolver authenticatedActorResolver() { return new HandlerMethodArgumentResolver() { @Override diff --git a/src/test/java/com/process/clash/application/ranking/service/GetChapterRankingServiceTest.java b/src/test/java/com/process/clash/application/ranking/service/GetChapterRankingServiceTest.java index 2c38fd507..cb196f895 100644 --- a/src/test/java/com/process/clash/application/ranking/service/GetChapterRankingServiceTest.java +++ b/src/test/java/com/process/clash/application/ranking/service/GetChapterRankingServiceTest.java @@ -116,6 +116,42 @@ void execute_myRankNotInAllRankers_whenRankAbove20() { .doesNotContain(MY_USER_ID); } + @Test + @DisplayName("실제 클리어 챕터 수는 myRank와 allRankers에 그대로 반영된다") + void execute_mapsActualClearedChapterCount() { + Long otherUserId = 2L; + List rows = new ArrayList<>(); + rows.add(record(otherUserId, 1, 12, "NONE", "PLATINUM")); + rows.add(record(MY_USER_ID, 2, 7, "NONE", "GOLD")); + when(loadChapterRankingPort.loadRankingsWithMyRank(MY_USER_ID)).thenReturn(rows); + + GetChapterRankingData.Result result = getChapterRankingService.execute( + GetChapterRankingData.Command.from(new Actor(MY_USER_ID))); + + assertThat(result.myRank().completedChaptersCount()).isEqualTo(7); + assertThat(result.allRankers()) + .extracting(GetChapterRankingData.RankersVo::completedChaptersCount) + .containsExactly(12, 7); + } + + @Test + @DisplayName("동점 랭킹은 저장소가 준 rank 값을 그대로 유지한다") + void execute_keepsRankValueWhenTied() { + Long otherUserId = 2L; + List rows = new ArrayList<>(); + rows.add(record(otherUserId, 1, 8, "NONE", "GOLD")); + rows.add(record(MY_USER_ID, 1, 8, "NONE", "SILVER")); + when(loadChapterRankingPort.loadRankingsWithMyRank(MY_USER_ID)).thenReturn(rows); + + GetChapterRankingData.Result result = getChapterRankingService.execute( + GetChapterRankingData.Command.from(new Actor(MY_USER_ID))); + + assertThat(result.myRank().rank()).isEqualTo(1); + assertThat(result.allRankers()) + .extracting(GetChapterRankingData.RankersVo::rank) + .containsExactly(1, 1); + } + // ── helpers ─────────────────────────────────────────────────────────────── private Object[] record(Long userId, int rank, int completedCount, @@ -123,4 +159,4 @@ private Object[] record(Long userId, int rank, int completedCount, return new Object[]{userId, "user" + userId, "https://img/" + userId, completedCount, rank, rankTier, expTier}; } -} \ No newline at end of file +} diff --git a/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java b/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java index d230935c4..ddac066a1 100644 --- a/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java +++ b/src/test/java/com/process/clash/application/roadmap/v2/question/service/SubmitQuestionV2AnswerServiceTest.java @@ -14,6 +14,7 @@ import com.process.clash.domain.roadmap.v2.entity.ChapterV2; import com.process.clash.domain.roadmap.v2.entity.ChoiceV2; import com.process.clash.domain.roadmap.v2.entity.QuestionV2; +import com.process.clash.domain.roadmap.v2.entity.UserQuestionHistoryV2; import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.user.enums.Role; import com.process.clash.domain.user.user.enums.UserStatus; @@ -33,6 +34,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -106,6 +109,8 @@ void execute_grantsChapterClearCookieReward() { assertThat(userCaptor.getValue().totalCookie()).isEqualTo(1100); assertThat(historyCaptor.getValue().goodsActingCategory()).isEqualTo(GoodsActingCategory.ROADMAP_V2_CHAPTER_REWARD); assertThat(historyCaptor.getValue().variation()).isEqualTo(100); + assertThat(progress.getCompletedChapters()).isEqualTo(1); + assertThat(progress.getCurrentChapterId()).isEqualTo(21L); } @Test @@ -146,6 +151,179 @@ void execute_grantsChapterAndSectionClearCookieRewards() { org.assertj.core.groups.Tuple.tuple(GoodsActingCategory.ROADMAP_V2_SECTION_REWARD, 1000) ); assertThat(progress.getIsCompleted()).isTrue(); + assertThat(progress.getCompletedChapters()).isEqualTo(1); + } + + @Test + @DisplayName("이미 클리어한 챕터를 다시 클리어해도 진행도와 쿠키는 증가하지 않는다") + void execute_doesNotGrantRewardOrProgressOnReplayClear() { + Actor actor = new Actor(1L); + ChoiceV2 correctChoice = new ChoiceV2(100L, 10L, "정답", true, 0); + QuestionV2 question = new QuestionV2(10L, 20L, "질문", "해설", 0, 1, List.of(correctChoice)); + ChapterV2 chapter = new ChapterV2(20L, 30L, "챕터 1", "설명", 0, null, List.of(question)); + ChapterV2 nextChapter = new ChapterV2(21L, 30L, "챕터 2", "설명", 1, null, List.of()); + UserSectionProgress progress = new UserSectionProgress(1L, actor.id(), 30L, 20L, 7, false); + UserQuestionHistoryV2 clearedHistory = new UserQuestionHistoryV2(1L, actor.id(), 20L, true, 1, 1, 1); + + when(questionV2RepositoryPort.findById(10L)).thenReturn(Optional.of(question)); + when(chapterV2RepositoryPort.findByIdWithQuestions(20L)).thenReturn(Optional.of(chapter)); + when(chapterV2RepositoryPort.findById(20L)).thenReturn(Optional.of(chapter)); + when(userSectionProgressRepositoryPort.findByUserIdAndSectionId(actor.id(), 30L)).thenReturn(Optional.of(progress)); + when(userQuestionHistoryV2RepositoryPort.findByUserIdAndChapterId(actor.id(), 20L)).thenReturn(Optional.of(clearedHistory)); + + SubmitQuestionV2AnswerData.Result result = service.execute( + new SubmitQuestionV2AnswerData.Command(actor, 10L, 100L) + ); + + verify(userQuestionHistoryV2RepositoryPort).save(any(UserQuestionHistoryV2.class)); + verify(userRepositoryPort, never()).findByIdForUpdate(actor.id()); + verify(userRepositoryPort, never()).save(any(User.class)); + verify(userGoodsHistoryRepositoryPort, never()).save(any(UserGoodsHistory.class)); + verify(userSectionProgressRepositoryPort, never()).save(any(UserSectionProgress.class)); + verify(chapterV2RepositoryPort, never()).findAllBySectionId(30L); + + assertThat(result.isChapterCleared()).isTrue(); + assertThat(result.nextChapterId()).isNull(); + assertThat(progress.getCompletedChapters()).isEqualTo(7); + assertThat(progress.getCurrentChapterId()).isEqualTo(20L); + assertThat(clearedHistory.getCurrentQuestionIndex()).isEqualTo(1); + assertThat(clearedHistory.getCorrectCount()).isEqualTo(1); + assertThat(clearedHistory.isCleared()).isTrue(); + } + + @Test + @DisplayName("진행도 레코드가 없는 첫 챕터를 최초 클리어하면 진행도가 생성되고 다음 챕터로 이동한다") + void execute_startsProgressWhenFirstChapterClearedWithoutExistingProgress() { + Actor actor = new Actor(1L); + ChoiceV2 correctChoice = new ChoiceV2(100L, 10L, "정답", true, 0); + QuestionV2 question = new QuestionV2(10L, 20L, "질문", "해설", 0, 1, List.of(correctChoice)); + ChapterV2 chapter = new ChapterV2(20L, 30L, "챕터 1", "설명", 0, null, List.of(question)); + ChapterV2 nextChapter = new ChapterV2(21L, 30L, "챕터 2", "설명", 1, null, List.of()); + User user = createUser(actor.id(), 100); + + when(questionV2RepositoryPort.findById(10L)).thenReturn(Optional.of(question)); + when(chapterV2RepositoryPort.findByIdWithQuestions(20L)).thenReturn(Optional.of(chapter)); + when(userSectionProgressRepositoryPort.findByUserIdAndSectionId(actor.id(), 30L)).thenReturn(Optional.empty()); + when(userQuestionHistoryV2RepositoryPort.findByUserIdAndChapterId(actor.id(), 20L)).thenReturn(Optional.empty()); + when(chapterV2RepositoryPort.findAllBySectionId(30L)).thenReturn(List.of(chapter, nextChapter)); + when(userRepositoryPort.findByIdForUpdate(actor.id())).thenReturn(Optional.of(user)); + + SubmitQuestionV2AnswerData.Result result = service.execute( + new SubmitQuestionV2AnswerData.Command(actor, 10L, 100L) + ); + + ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserSectionProgress.class); + + verify(userSectionProgressRepositoryPort).save(progressCaptor.capture()); + verify(userGoodsHistoryRepositoryPort).save(any(UserGoodsHistory.class)); + + UserSectionProgress savedProgress = progressCaptor.getValue(); + assertThat(result.isChapterCleared()).isTrue(); + assertThat(result.nextChapterId()).isEqualTo(21L); + assertThat(savedProgress.getCompletedChapters()).isEqualTo(1); + assertThat(savedProgress.getCurrentChapterId()).isEqualTo(21L); + assertThat(savedProgress.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("오답 제출은 보상과 진행도 증가를 발생시키지 않는다") + void execute_doesNotGrantRewardOrProgressOnWrongAnswer() { + Actor actor = new Actor(1L); + ChoiceV2 wrongChoice = new ChoiceV2(101L, 10L, "오답", false, 0); + ChoiceV2 correctChoice = new ChoiceV2(100L, 10L, "정답", true, 1); + QuestionV2 question = new QuestionV2(10L, 20L, "질문", "해설", 0, 1, List.of(wrongChoice, correctChoice)); + ChapterV2 chapter = new ChapterV2(20L, 30L, "챕터 1", "설명", 0, null, List.of(question)); + UserSectionProgress progress = UserSectionProgress.start(actor.id(), 30L, 20L); + + when(questionV2RepositoryPort.findById(10L)).thenReturn(Optional.of(question)); + when(chapterV2RepositoryPort.findByIdWithQuestions(20L)).thenReturn(Optional.of(chapter)); + when(chapterV2RepositoryPort.findById(20L)).thenReturn(Optional.of(chapter)); + when(userSectionProgressRepositoryPort.findByUserIdAndSectionId(actor.id(), 30L)).thenReturn(Optional.of(progress)); + when(userQuestionHistoryV2RepositoryPort.findByUserIdAndChapterId(actor.id(), 20L)).thenReturn(Optional.empty()); + + SubmitQuestionV2AnswerData.Result result = service.execute( + new SubmitQuestionV2AnswerData.Command(actor, 10L, 101L) + ); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(UserQuestionHistoryV2.class); + + verify(userQuestionHistoryV2RepositoryPort).save(historyCaptor.capture()); + verify(userRepositoryPort, never()).findByIdForUpdate(actor.id()); + verify(userRepositoryPort, never()).save(any(User.class)); + verify(userGoodsHistoryRepositoryPort, never()).save(any(UserGoodsHistory.class)); + verify(userSectionProgressRepositoryPort, never()).save(any(UserSectionProgress.class)); + + UserQuestionHistoryV2 savedHistory = historyCaptor.getValue(); + assertThat(result.isCorrect()).isFalse(); + assertThat(result.isChapterCleared()).isFalse(); + assertThat(savedHistory.getCorrectCount()).isEqualTo(0); + assertThat(savedHistory.getCurrentQuestionIndex()).isEqualTo(1); + assertThat(savedHistory.isCleared()).isFalse(); + assertThat(progress.getCompletedChapters()).isEqualTo(0); + assertThat(progress.getCurrentChapterId()).isEqualTo(20L); + } + + @Test + @DisplayName("이미 클리어한 중간 챕터를 다시 클리어해도 현재 진행 챕터는 바뀌지 않는다") + void execute_doesNotMoveCurrentChapterOnReplayClearOfMiddleChapter() { + Actor actor = new Actor(1L); + ChoiceV2 correctChoice = new ChoiceV2(100L, 10L, "정답", true, 0); + QuestionV2 question = new QuestionV2(10L, 20L, "질문", "해설", 0, 1, List.of(correctChoice)); + ChapterV2 chapter = new ChapterV2(20L, 30L, "챕터 1", "설명", 0, null, List.of(question)); + ChapterV2 currentChapter = new ChapterV2(21L, 30L, "챕터 2", "설명", 1, null, List.of()); + UserSectionProgress progress = new UserSectionProgress(1L, actor.id(), 30L, 21L, 4, false); + UserQuestionHistoryV2 clearedHistory = new UserQuestionHistoryV2(1L, actor.id(), 20L, true, 1, 1, 1); + + when(questionV2RepositoryPort.findById(10L)).thenReturn(Optional.of(question)); + when(chapterV2RepositoryPort.findByIdWithQuestions(20L)).thenReturn(Optional.of(chapter)); + when(chapterV2RepositoryPort.findById(21L)).thenReturn(Optional.of(currentChapter)); + when(userSectionProgressRepositoryPort.findByUserIdAndSectionId(actor.id(), 30L)).thenReturn(Optional.of(progress)); + when(userQuestionHistoryV2RepositoryPort.findByUserIdAndChapterId(actor.id(), 20L)).thenReturn(Optional.of(clearedHistory)); + + SubmitQuestionV2AnswerData.Result result = service.execute( + new SubmitQuestionV2AnswerData.Command(actor, 10L, 100L) + ); + + verify(userRepositoryPort, never()).findByIdForUpdate(actor.id()); + verify(userGoodsHistoryRepositoryPort, never()).save(any(UserGoodsHistory.class)); + verify(userSectionProgressRepositoryPort, never()).save(any(UserSectionProgress.class)); + + assertThat(result.isChapterCleared()).isTrue(); + assertThat(progress.getCurrentChapterId()).isEqualTo(21L); + assertThat(progress.getCompletedChapters()).isEqualTo(4); + } + + @Test + @DisplayName("첫 문제 재시작으로 reset이 발생해도 최초 클리어 판정은 false로 유지된다") + void execute_keepsReplayAsNotFirstClearWhenResetOccurs() { + Actor actor = new Actor(1L); + ChoiceV2 correctChoice = new ChoiceV2(100L, 10L, "정답", true, 0); + ChoiceV2 secondChoice = new ChoiceV2(200L, 11L, "정답2", true, 0); + QuestionV2 firstQuestion = new QuestionV2(10L, 20L, "질문1", "해설1", 0, 1, List.of(correctChoice)); + QuestionV2 secondQuestion = new QuestionV2(11L, 20L, "질문2", "해설2", 1, 1, List.of(secondChoice)); + ChapterV2 chapter = new ChapterV2(20L, 30L, "챕터 1", "설명", 0, null, List.of(firstQuestion, secondQuestion)); + UserSectionProgress progress = new UserSectionProgress(1L, actor.id(), 30L, 20L, 2, true); + UserQuestionHistoryV2 clearedHistory = new UserQuestionHistoryV2(1L, actor.id(), 20L, true, 2, 2, 2); + + when(questionV2RepositoryPort.findById(10L)).thenReturn(Optional.of(firstQuestion)); + when(chapterV2RepositoryPort.findByIdWithQuestions(20L)).thenReturn(Optional.of(chapter)); + when(chapterV2RepositoryPort.findById(20L)).thenReturn(Optional.of(chapter)); + when(userSectionProgressRepositoryPort.findByUserIdAndSectionId(actor.id(), 30L)).thenReturn(Optional.of(progress)); + when(userQuestionHistoryV2RepositoryPort.findByUserIdAndChapterId(actor.id(), 20L)).thenReturn(Optional.of(clearedHistory)); + + SubmitQuestionV2AnswerData.Result result = service.execute( + new SubmitQuestionV2AnswerData.Command(actor, 10L, 100L) + ); + + verify(userRepositoryPort, never()).findByIdForUpdate(actor.id()); + verify(userGoodsHistoryRepositoryPort, never()).save(any(UserGoodsHistory.class)); + verify(userSectionProgressRepositoryPort, never()).save(any(UserSectionProgress.class)); + + assertThat(result.isChapterCleared()).isFalse(); + assertThat(clearedHistory.getCorrectCount()).isEqualTo(1); + assertThat(clearedHistory.getCurrentQuestionIndex()).isEqualTo(1); + assertThat(clearedHistory.isCleared()).isFalse(); + assertThat(progress.getCompletedChapters()).isEqualTo(2); } private User createUser(Long userId, int totalCookie) { diff --git a/src/test/java/com/process/clash/application/roadmap/v2/section/service/GetSectionV2DetailsServiceTest.java b/src/test/java/com/process/clash/application/roadmap/v2/section/service/GetSectionV2DetailsServiceTest.java index b5b3d4405..dc1d38110 100644 --- a/src/test/java/com/process/clash/application/roadmap/v2/section/service/GetSectionV2DetailsServiceTest.java +++ b/src/test/java/com/process/clash/application/roadmap/v2/section/service/GetSectionV2DetailsServiceTest.java @@ -66,6 +66,25 @@ void execute_returnsCompletedFromProgress() { assertThat(result.currentOrderIndex()).isEqualTo(0); } + @Test + @DisplayName("완료된 섹션은 currentOrderIndex와 상관없이 completed 필드로 완료 여부를 표현한다") + void execute_keepsCompletedTrueEvenWhenCurrentOrderIndexIsNull() { + Actor actor = new Actor(1L); + Section section = createSection(10L, "Spring"); + ChapterV2 chapter = new ChapterV2(100L, 10L, "Intro", "desc", 0, "https://example.com", List.of()); + UserSectionProgress progress = new UserSectionProgress(1L, 1L, 10L, 999L, 3, true); + + when(sectionRepository.findById(10L)).thenReturn(Optional.of(section)); + when(chapterV2RepositoryPort.findAllBySectionId(10L)).thenReturn(List.of(chapter)); + when(userSectionProgressRepository.findByUserIdAndSectionId(1L, 10L)).thenReturn(Optional.of(progress)); + + GetSectionV2DetailsData.Result result = service.execute(new GetSectionV2DetailsData.Command(actor, 10L)); + + assertThat(result.completed()).isTrue(); + assertThat(result.currentChapterId()).isEqualTo(999L); + assertThat(result.currentOrderIndex()).isNull(); + } + @Test @DisplayName("섹션 진행도가 없으면 completed 필드는 false다") void execute_returnsFalseWhenProgressMissing() { @@ -82,6 +101,7 @@ void execute_returnsFalseWhenProgressMissing() { assertThat(result.completed()).isFalse(); assertThat(result.currentChapterId()).isNull(); assertThat(result.currentOrderIndex()).isNull(); + assertThat(result.totalChapters()).isEqualTo(1); } private Section createSection(Long sectionId, String title) {