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
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ public interface UserSectionProgressJpaRepository extends JpaRepository<UserSect
u.id as userId,
u.name as userName,
u.profile_image as profileImage,
COALESCE(SUM(usp.completed_chapters), 0) as totalCompleted,
RANK() OVER (ORDER BY COALESCE(SUM(usp.completed_chapters), 0) DESC) as userRank,
COALESCE(COUNT(DISTINCT uqh.fk_chapter_id), 0) as totalCompleted,
RANK() OVER (ORDER BY COALESCE(COUNT(DISTINCT uqh.fk_chapter_id), 0) DESC) as userRank,
Comment thread
hjbin-25 marked this conversation as resolved.
u.current_rank_tier as currentRankTier,
u.current_exp_tier as currentExpTier
FROM users u
LEFT JOIN user_section_progress usp ON u.id = usp.fk_user_id
LEFT JOIN user_question_history_v2 uqh
ON u.id = uqh.fk_user_id
AND uqh.is_cleared = true
Comment thread
hjbin-25 marked this conversation as resolved.
GROUP BY u.id
) rankTable
WHERE userId = :targetUserId OR userRank <= 20
ORDER BY userRank ASC
""", nativeQuery = true)
List<Object[]> findRankingsWithMyRank(@Param("targetUserId") Long targetUserId);
}

Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -36,6 +39,7 @@
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.stream.IntStream;

@DataJpaTest
@ActiveProfiles("test")
Expand All @@ -52,6 +56,7 @@
ChapterV2JpaMapper.class,
QuestionV2JpaMapper.class,
ChoiceV2JpaMapper.class,
UserQuestionHistoryV2JpaMapper.class,
SectionJpaMapper.class
})
class UserSectionProgressJpaRepositoryTest {
Expand All @@ -70,6 +75,10 @@ class UserSectionProgressJpaRepositoryTest {

UserJpaEntity noSectionProgressUserJpaEntity;
UserJpaEntity sectionProgressUserJpaEntity;
ChapterV2JpaEntity chapterJpaEntity;
ChapterV2JpaEntity secondChapterJpaEntity;
ChapterV2JpaEntity thirdChapterJpaEntity;
SectionJpaEntity sectionJpaEntity;

@BeforeEach
void beforeEach() {
Expand All @@ -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",
Expand Down Expand Up @@ -119,13 +136,180 @@ void beforeEach() {
}

@Test
@DisplayName("실제 클리어 히스토리만 랭킹 점수로 집계된다")
void findRankingsWithMyRank() {
persistHistory(sectionProgressUserJpaEntity, chapterJpaEntity, true);
em.flush();

List<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<ChapterV2JpaEntity> chapters = new ArrayList<>();
chapters.add(chapterJpaEntity);
chapters.add(secondChapterJpaEntity);
chapters.add(thirdChapterJpaEntity);

IntStream.rangeClosed(4, 21).forEach(index -> chapters.add(persistAdditionalChapter(index)));

List<UserJpaEntity> 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<Object[]> 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<Object[]> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,47 @@ void execute_myRankNotInAllRankers_whenRankAbove20() {
.doesNotContain(MY_USER_ID);
}

@Test
@DisplayName("실제 클리어 챕터 수는 myRank와 allRankers에 그대로 반영된다")
void execute_mapsActualClearedChapterCount() {
Long otherUserId = 2L;
List<Object[]> 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<Object[]> 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,
String rankTier, String expTier) {
return new Object[]{userId, "user" + userId, "https://img/" + userId,
completedCount, rank, rankTier, expTier};
}
}
}
Loading
Loading