diff --git a/.claude/skills/review-fix-plan/SKILL.md b/.claude/skills/review-fix-plan/SKILL.md new file mode 100644 index 00000000..3621ca7c --- /dev/null +++ b/.claude/skills/review-fix-plan/SKILL.md @@ -0,0 +1,88 @@ +--- +name: review-fix-plan +description: | + 코드 리뷰 후 수정 계획 문서를 생성하는 스킬. + 코드 리뷰 결과를 구조화된 마크다운 문서로 docs/local/ 하위에 저장한다. + "리뷰 문서 만들어줘", "코드리뷰 정리해줘", "/review-fix-plan" 요청 시 사용. +--- + +# Review Fix Plan + +코드 리뷰 결과를 **문제상황 → 문제원인 → 해결 방법 → 결과** 4단계 구조로 정리하여 +`docs/local/` 하위에 마크다운 문서로 저장한다. + +## When to Use + +- 코드 리뷰 수행 후 이슈를 문서로 정리하고 싶을 때 +- "리뷰 문서 만들어줘", "코드리뷰 정리해줘" 요청 시 +- "/review-fix-plan" 슬래시 커맨드 입력 시 +- PR 리뷰 후 수정 사항을 추적하고 싶을 때 + +## Process + +1. **리뷰 결과 수집**: 현재 대화에서 발견된 코드 리뷰 이슈 파악 + - 각 이슈의 심각도 판단 (CRITICAL / WARNING / LOW) + - 관련 파일과 코드 위치 식별 + +2. **문서 작성**: 아래 템플릿에 맞춰 각 이슈를 정리 + +3. **파일 저장**: `docs/local/{도메인}-review-fixes.md` 경로에 저장 + - 기존 파일이 있으면 덮어쓰기 (같은 PR/작업 단위) + +4. **완료 보고**: 저장된 파일 경로를 사용자에게 알림 + +## Template + +```markdown +# {작업명} 코드 리뷰 이슈 분석 ({날짜}) + +## 수정 파일 + +- `{파일 경로 1}` +- `{파일 경로 2}` + +--- + +## [{심각도 코드}] {위치} — {이슈 한줄 요약} + +**심각도**: {이모지} {CRITICAL | WARNING | LOW} + +**문제 상황**: {어떤 동작이 문제인지 사용자 관점에서 서술} + +**문제 원인**: {왜 이 문제가 발생했는지 코드 레벨에서 분석} + +```java +// AS-IS — 문제 코드 +{기존 코드} +``` + +**해결 방법**: {구체적인 수정 방안 서술} + +```java +// TO-BE — 개선 코드 +{수정 코드} +``` + +**결과**: {수정 완료 여부, 수정 후 기대 효과} + +--- + +## 패턴 정리 + +| 상황 | 패턴 | +|------|------| +| {이번 리뷰에서 얻은 교훈} | {재발 방지를 위한 규칙} | +``` + +## Severity Guide + +| 코드 | 이모지 | 기준 | +|------|--------|------| +| C-N | 🔴 CRITICAL | 잘못된 데이터 반환, 런타임 에러, 보안 취약점 | +| W-N | 🟡 WARNING | 성능 저하, 중복 코드, 유지보수 위험 | +| L-N | 🟢 LOW | dead code, 네이밍, 스타일 | + +## Naming Convention + +- 파일명: `{도메인}-review-fixes.md` (예: `feed-modify-existing-review-fixes.md`) +- 이슈 ID: `[{심각도 첫글자}-{번호}]` (예: `[C-1]`, `[W-2]`, `[L-1]`) diff --git a/CLAUDE.md b/CLAUDE.md index 3a2ef39c..3971f734 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ - 완전히 실행 가능한 코드만 제공한다 (의사코드 금지) - `@Valid`, `@NotNull` 등 DTO 검증 어노테이션 적용 - secrets/환경변수 하드코딩 절대 금지 +- **변수명은 의미를 알 수 있도록 작성한다** — `dto`, `r`, `p` 같은 축약 금지, 역할이 드러나는 이름 사용 (예: `dto` → `rawRanking`, `r` → `ranking`) ### 새 기능 추가 시 필수 순서 1. (DB 변경 시) `resources/db/migration/`에 Flyway 마이그레이션 파일 추가 @@ -31,6 +32,9 @@ - 버그 수정/기능 추가 시 반드시 테스트 추가 - TestContainers 사용 — Docker 실행 상태 필요 - **FixtureMonkey 사용 금지** — 테스트 데이터는 `common/fixture/` 의 static 메서드로만 생성한다 +- `@DisplayName`에 메서드명을 넣지 않고, **테스트만 보고 요구사항을 파악할 수 있는 문장**으로 작성한다 + - 좋은 예: `"피드가 없으면 모든 집계가 0이다"`, `"삭제된 피드는 조회되지 않는다"` + - 나쁜 예: `"findMyFeedStat - 피드가 없으면 0"`, `"getAllFeeds는 삭제된 피드 제외"` --- @@ -39,6 +43,7 @@ - 브랜치명: `{type}/{DDING-이슈번호}-{설명}` (예: `feat/DDING-123-club-search`) - 커밋 메시지: 한국어, `[DDING-000] 작업 내용` 형식 - PR 템플릿: 🚀 작업 내용 / 🤔 고민했던 내용 / 💬 리뷰 중점사항 +- PR 본문 작성 시 특정 클래스명, 메서드명을 나열하지 않고 **작업 내용 중심의 자연스러운 글**로 작성한다 (가독성 우선) ### API 단위 브랜치 전략 (PR 크기 관리) diff --git a/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/AdminClubFeedRankingResponse.java b/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/AdminClubFeedRankingResponse.java index c7f5bc06..07f714cf 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/AdminClubFeedRankingResponse.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/AdminClubFeedRankingResponse.java @@ -7,26 +7,24 @@ @Builder public record AdminClubFeedRankingResponse( int rank, - Long clubId, String clubName, - long feedCount, - long viewCount, - long likeCount, - long commentCount, - long score + long feedScore, + long viewScore, + long likeScore, + long commentScore, + long totalScore ) { public static List from(List queries) { return queries.stream() .map(query -> AdminClubFeedRankingResponse.builder() .rank(query.rank()) - .clubId(query.clubId()) .clubName(query.clubName()) - .feedCount(query.feedCount()) - .viewCount(query.viewCount()) - .likeCount(query.likeCount()) - .commentCount(query.commentCount()) - .score(query.score()) + .feedScore(query.feedScore()) + .viewScore(query.viewScore()) + .likeScore(query.likeScore()) + .commentScore(query.commentScore()) + .totalScore(query.totalScore()) .build()) .toList(); } diff --git a/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/ClubMonthlyStatusResponse.java b/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/ClubMonthlyStatusResponse.java index bee4d13f..ea04be30 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/ClubMonthlyStatusResponse.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/feed/controller/dto/response/ClubMonthlyStatusResponse.java @@ -8,11 +8,12 @@ public record ClubMonthlyStatusResponse( int year, int month, int rank, - long feedCount, - long viewCount, - long likeCount, - long commentCount, - long score + int lastMonthRank, + long feedScore, + long viewScore, + long likeScore, + long commentScore, + long totalScore ) { public static ClubMonthlyStatusResponse from(ClubMonthlyStatusQuery query) { @@ -20,11 +21,12 @@ public static ClubMonthlyStatusResponse from(ClubMonthlyStatusQuery query) { .year(query.year()) .month(query.month()) .rank(query.rank()) - .feedCount(query.feedCount()) - .viewCount(query.viewCount()) - .likeCount(query.likeCount()) - .commentCount(query.commentCount()) - .score(query.score()) + .lastMonthRank(query.lastMonthRank()) + .feedScore(query.feedScore()) + .viewScore(query.viewScore()) + .likeScore(query.likeScore()) + .commentScore(query.commentScore()) + .totalScore(query.totalScore()) .build(); } } diff --git a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingService.java b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingService.java index e8896a99..bf03f89d 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingService.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingService.java @@ -38,12 +38,12 @@ public List getClubFeedRanking(int year, int month) { long previousScore = Long.MAX_VALUE; int rank = 1; for (int i = 0; i < sorted.size(); i++) { - long score = calculateScore(sorted.get(i)); - if (i > 0 && score < previousScore) { + long totalScore = calculateScore(sorted.get(i)); + if (i > 0 && totalScore < previousScore) { rank = i + 1; } - result.add(ClubFeedRankingQuery.of(rank, sorted.get(i), score)); - previousScore = score; + result.add(toClubFeedRankingQuery(rank, sorted.get(i))); + previousScore = totalScore; } return result; @@ -53,19 +53,49 @@ public List getClubFeedRanking(int year, int month) { public ClubMonthlyStatusQuery getClubMonthlyStatus(Long userId, int year, int month) { Club club = clubService.getByUserId(userId); List rankings = getClubFeedRanking(year, month); + int lastMonthRank = getLastMonthRank(club.getId(), year, month); return rankings.stream() .filter(rankingQuery -> rankingQuery.clubId().equals(club.getId())) .findFirst() - .filter(rankingQuery -> rankingQuery.score() > 0) - .map(rankingQuery -> ClubMonthlyStatusQuery.from(year, month, rankingQuery)) - .orElse(ClubMonthlyStatusQuery.createEmpty(year, month)); + .filter(rankingQuery -> rankingQuery.totalScore() > 0) + .map(rankingQuery -> toMonthlyStatus(year, month, rankingQuery, lastMonthRank)) + .orElse(ClubMonthlyStatusQuery.createEmpty(year, month, lastMonthRank)); } - private long calculateScore(MonthlyFeedRankingDto dto) { - return dto.getFeedCount() * FEED_WEIGHT - + dto.getViewCount() * VIEW_WEIGHT - + dto.getLikeCount() * LIKE_WEIGHT - + dto.getCommentCount() * COMMENT_WEIGHT; + private ClubMonthlyStatusQuery toMonthlyStatus(int year, int month, + ClubFeedRankingQuery ranking, int lastMonthRank) { + return ClubMonthlyStatusQuery.of(year, month, ranking.rank(), lastMonthRank, + ranking.feedScore(), ranking.viewScore(), ranking.likeScore(), ranking.commentScore()); + } + + private int getLastMonthRank(Long clubId, int year, int month) { + int lastYear = month == 1 ? year - 1 : year; + int lastMonth = month == 1 ? 12 : month - 1; + + List lastMonthRankings = getClubFeedRanking(lastYear, lastMonth); + return lastMonthRankings.stream() + .filter(ranking -> ranking.clubId().equals(clubId)) + .filter(ranking -> ranking.totalScore() > 0) + .findFirst() + .map(ClubFeedRankingQuery::rank) + .orElse(0); + } + + private ClubFeedRankingQuery toClubFeedRankingQuery(int rank, MonthlyFeedRankingDto rawRanking) { + long feedScore = rawRanking.getFeedCount() * FEED_WEIGHT; + long viewScore = rawRanking.getViewCount() * VIEW_WEIGHT; + long likeScore = rawRanking.getLikeCount() * LIKE_WEIGHT; + long commentScore = rawRanking.getCommentCount() * COMMENT_WEIGHT; + long totalScore = feedScore + viewScore + likeScore + commentScore; + return ClubFeedRankingQuery.of(rank, rawRanking.getClubId(), rawRanking.getClubName(), + feedScore, viewScore, likeScore, commentScore, totalScore); + } + + private long calculateScore(MonthlyFeedRankingDto rawRanking) { + return rawRanking.getFeedCount() * FEED_WEIGHT + + rawRanking.getViewCount() * VIEW_WEIGHT + + rawRanking.getLikeCount() * LIKE_WEIGHT + + rawRanking.getCommentCount() * COMMENT_WEIGHT; } } diff --git a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubFeedRankingQuery.java b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubFeedRankingQuery.java index cb533746..a8e23eeb 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubFeedRankingQuery.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubFeedRankingQuery.java @@ -1,6 +1,5 @@ package ddingdong.ddingdongBE.domain.feed.service.dto.query; -import ddingdong.ddingdongBE.domain.feed.repository.dto.MonthlyFeedRankingDto; import lombok.Builder; @Builder @@ -8,23 +7,24 @@ public record ClubFeedRankingQuery( int rank, Long clubId, String clubName, - long feedCount, - long viewCount, - long likeCount, - long commentCount, - long score + long feedScore, + long viewScore, + long likeScore, + long commentScore, + long totalScore ) { - public static ClubFeedRankingQuery of(int rank, MonthlyFeedRankingDto dto, long score) { + public static ClubFeedRankingQuery of(int rank, Long clubId, String clubName, + long feedScore, long viewScore, long likeScore, long commentScore, long totalScore) { return ClubFeedRankingQuery.builder() .rank(rank) - .clubId(dto.getClubId()) - .clubName(dto.getClubName()) - .feedCount(dto.getFeedCount()) - .viewCount(dto.getViewCount()) - .likeCount(dto.getLikeCount()) - .commentCount(dto.getCommentCount()) - .score(score) + .clubId(clubId) + .clubName(clubName) + .feedScore(feedScore) + .viewScore(viewScore) + .likeScore(likeScore) + .commentScore(commentScore) + .totalScore(totalScore) .build(); } } diff --git a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubMonthlyStatusQuery.java b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubMonthlyStatusQuery.java index da956cd8..c03418a4 100644 --- a/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubMonthlyStatusQuery.java +++ b/src/main/java/ddingdong/ddingdongBE/domain/feed/service/dto/query/ClubMonthlyStatusQuery.java @@ -7,36 +7,40 @@ public record ClubMonthlyStatusQuery( int year, int month, int rank, - long feedCount, - long viewCount, - long likeCount, - long commentCount, - long score + int lastMonthRank, + long feedScore, + long viewScore, + long likeScore, + long commentScore, + long totalScore ) { - public static ClubMonthlyStatusQuery from(int year, int month, ClubFeedRankingQuery ranking) { + public static ClubMonthlyStatusQuery of(int year, int month, int rank, int lastMonthRank, + long feedScore, long viewScore, long likeScore, long commentScore) { return ClubMonthlyStatusQuery.builder() .year(year) .month(month) - .rank(ranking.rank()) - .feedCount(ranking.feedCount()) - .viewCount(ranking.viewCount()) - .likeCount(ranking.likeCount()) - .commentCount(ranking.commentCount()) - .score(ranking.score()) + .rank(rank) + .lastMonthRank(lastMonthRank) + .feedScore(feedScore) + .viewScore(viewScore) + .likeScore(likeScore) + .commentScore(commentScore) + .totalScore(feedScore + viewScore + likeScore + commentScore) .build(); } - public static ClubMonthlyStatusQuery createEmpty(int year, int month) { + public static ClubMonthlyStatusQuery createEmpty(int year, int month, int lastMonthRank) { return ClubMonthlyStatusQuery.builder() .year(year) .month(month) .rank(0) - .feedCount(0) - .viewCount(0) - .likeCount(0) - .commentCount(0) - .score(0) + .lastMonthRank(lastMonthRank) + .feedScore(0) + .viewScore(0) + .likeScore(0) + .commentScore(0) + .totalScore(0) .build(); } } diff --git a/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/AdminFeedControllerE2ETest.java b/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/AdminFeedControllerE2ETest.java index 2aabe9f0..ebd2a4e7 100644 --- a/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/AdminFeedControllerE2ETest.java +++ b/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/AdminFeedControllerE2ETest.java @@ -97,10 +97,21 @@ void getClubFeedRanking_success() { .getList(".", AdminClubFeedRankingResponse.class); assertThat(response).hasSize(2); - assertThat(response.get(0).clubName()).isEqualTo("동아리B"); - assertThat(response.get(0).rank()).isEqualTo(1); - assertThat(response.get(1).clubName()).isEqualTo("동아리A"); - assertThat(response.get(1).rank()).isEqualTo(2); + + AdminClubFeedRankingResponse first = response.get(0); + assertThat(first.clubName()).isEqualTo("동아리B"); + assertThat(first.rank()).isEqualTo(1); + assertThat(first.feedScore()).isEqualTo(2 * 10); + assertThat(first.viewScore()).isEqualTo(0); + assertThat(first.likeScore()).isEqualTo(1 * 3); + assertThat(first.commentScore()).isEqualTo(0); + assertThat(first.totalScore()).isEqualTo(23); + + AdminClubFeedRankingResponse second = response.get(1); + assertThat(second.clubName()).isEqualTo("동아리A"); + assertThat(second.rank()).isEqualTo(2); + assertThat(second.feedScore()).isEqualTo(1 * 10); + assertThat(second.totalScore()).isEqualTo(10); } @DisplayName("총동연 피드 랭킹 조회 API - 성공: 데이터 없으면 빈 리스트 반환") diff --git a/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/ClubFeedStatusE2ETest.java b/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/ClubFeedStatusE2ETest.java index 713c8639..53d98bda 100644 --- a/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/ClubFeedStatusE2ETest.java +++ b/src/test/java/ddingdong/ddingdongBE/domain/feed/controller/ClubFeedStatusE2ETest.java @@ -91,14 +91,16 @@ void getFeedStatus_success() { .extract() .as(Map.class); + // feedScore = 2*10=20, viewScore = 0, likeScore = 1*3=3, commentScore = 1*5=5, totalScore = 28 assertSoftly(softly -> { softly.assertThat(response.get("year")).isEqualTo(year); softly.assertThat(response.get("month")).isEqualTo(month); - softly.assertThat(((Number) response.get("feedCount")).longValue()).isEqualTo(2L); - softly.assertThat(((Number) response.get("likeCount")).longValue()).isEqualTo(1L); - softly.assertThat(((Number) response.get("commentCount")).longValue()).isEqualTo(1L); - softly.assertThat(((Number) response.get("score")).longValue()).isEqualTo(28L); + softly.assertThat(((Number) response.get("feedScore")).longValue()).isEqualTo(20L); + softly.assertThat(((Number) response.get("likeScore")).longValue()).isEqualTo(3L); + softly.assertThat(((Number) response.get("commentScore")).longValue()).isEqualTo(5L); + softly.assertThat(((Number) response.get("totalScore")).longValue()).isEqualTo(28L); softly.assertThat(((Number) response.get("rank")).intValue()).isEqualTo(1); + softly.assertThat(((Number) response.get("lastMonthRank")).intValue()).isEqualTo(0); }); } diff --git a/src/test/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingServiceTest.java b/src/test/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingServiceTest.java index 456c3466..2fb2b82d 100644 --- a/src/test/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingServiceTest.java +++ b/src/test/java/ddingdong/ddingdongBE/domain/feed/service/GeneralFeedRankingServiceTest.java @@ -17,12 +17,15 @@ import ddingdong.ddingdongBE.domain.feed.service.dto.query.ClubMonthlyStatusQuery; import ddingdong.ddingdongBE.domain.user.entity.User; import ddingdong.ddingdongBE.domain.user.repository.UserRepository; +import java.sql.Timestamp; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; @SpringBootTest class GeneralFeedRankingServiceTest extends TestContainerSupport { @@ -45,6 +48,9 @@ class GeneralFeedRankingServiceTest extends TestContainerSupport { @Autowired private UserRepository userRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @DisplayName("동아리별 피드 랭킹 조회 - 성공: 점수 높은 순서로 정렬된다") @Test void getClubFeedRanking_sortedByScore() { @@ -70,10 +76,10 @@ void getClubFeedRanking_sortedByScore() { assertSoftly(softly -> { softly.assertThat(result.get(0).clubName()).isEqualTo("동아리B"); softly.assertThat(result.get(0).rank()).isEqualTo(1); - softly.assertThat(result.get(0).score()).isEqualTo(20L); + softly.assertThat(result.get(0).totalScore()).isEqualTo(20L); softly.assertThat(result.get(1).clubName()).isEqualTo("동아리A"); softly.assertThat(result.get(1).rank()).isEqualTo(2); - softly.assertThat(result.get(1).score()).isEqualTo(10L); + softly.assertThat(result.get(1).totalScore()).isEqualTo(10L); }); } @@ -102,12 +108,12 @@ void getClubFeedRanking_tieRanking() { assertThat(result).hasSize(3); assertSoftly(softly -> { softly.assertThat(result.get(0).rank()).isEqualTo(1); - softly.assertThat(result.get(0).score()).isEqualTo(20L); + softly.assertThat(result.get(0).totalScore()).isEqualTo(20L); // 동점자 2명 → 둘 다 2위 softly.assertThat(result.get(1).rank()).isEqualTo(2); - softly.assertThat(result.get(1).score()).isEqualTo(10L); + softly.assertThat(result.get(1).totalScore()).isEqualTo(10L); softly.assertThat(result.get(2).rank()).isEqualTo(2); - softly.assertThat(result.get(2).score()).isEqualTo(10L); + softly.assertThat(result.get(2).totalScore()).isEqualTo(10L); }); } @@ -130,9 +136,9 @@ void getClubFeedRanking_includesClubsWithNoFeeds() { assertThat(result).hasSize(2); assertSoftly(softly -> { softly.assertThat(result.get(0).clubName()).isEqualTo("피드있는동아리"); - softly.assertThat(result.get(0).score()).isEqualTo(10L); + softly.assertThat(result.get(0).totalScore()).isEqualTo(10L); softly.assertThat(result.get(1).clubName()).isEqualTo("피드없는동아리"); - softly.assertThat(result.get(1).score()).isEqualTo(0L); + softly.assertThat(result.get(1).totalScore()).isEqualTo(0L); }); } @@ -150,8 +156,8 @@ void getClubFeedRanking_noFeedsInMonth() { assertThat(result).hasSize(1); assertSoftly(softly -> { softly.assertThat(result.get(0).clubName()).isEqualTo("동아리"); - softly.assertThat(result.get(0).feedCount()).isEqualTo(0); - softly.assertThat(result.get(0).score()).isEqualTo(0L); + softly.assertThat(result.get(0).feedScore()).isEqualTo(0); + softly.assertThat(result.get(0).totalScore()).isEqualTo(0L); }); } @@ -190,14 +196,14 @@ void getClubFeedRanking_withLikesAndComments() { assertThat(result).hasSize(1); assertSoftly(softly -> { softly.assertThat(result.get(0).clubName()).isEqualTo("활발한동아리"); - softly.assertThat(result.get(0).feedCount()).isEqualTo(1); - softly.assertThat(result.get(0).likeCount()).isEqualTo(2); - softly.assertThat(result.get(0).commentCount()).isEqualTo(1); - softly.assertThat(result.get(0).score()).isEqualTo(21L); + softly.assertThat(result.get(0).feedScore()).isEqualTo(10L); + softly.assertThat(result.get(0).likeScore()).isEqualTo(6L); + softly.assertThat(result.get(0).commentScore()).isEqualTo(5L); + softly.assertThat(result.get(0).totalScore()).isEqualTo(21L); }); } - @DisplayName("동아리 이달의 현황 조회 - 성공: 피드가 있으면 내 동아리 통계와 rank가 반환된다") + @DisplayName("동아리 이달의 현황 조회 - 성공: 피드가 있으면 가중치 점수와 rank가 반환된다") @Test void getClubMonthlyStatus_withFeeds() { // given @@ -213,17 +219,21 @@ void getClubMonthlyStatus_withFeeds() { ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus(user.getId(), year, month); // then - // score = feedCount(2)*10 = 20 + // feedScore = feedCount(2)*10 = 20, viewScore = 0, likeScore = 0, commentScore = 0 assertSoftly(softly -> { softly.assertThat(result.year()).isEqualTo(year); softly.assertThat(result.month()).isEqualTo(month); softly.assertThat(result.rank()).isEqualTo(1); - softly.assertThat(result.feedCount()).isEqualTo(2L); - softly.assertThat(result.score()).isEqualTo(20L); + softly.assertThat(result.feedScore()).isEqualTo(20L); + softly.assertThat(result.viewScore()).isEqualTo(0L); + softly.assertThat(result.likeScore()).isEqualTo(0L); + softly.assertThat(result.commentScore()).isEqualTo(0L); + softly.assertThat(result.totalScore()).isEqualTo(20L); + softly.assertThat(result.lastMonthRank()).isEqualTo(0); }); } - @DisplayName("동아리 이달의 현황 조회 - 성공: 피드가 없으면 모든 값 0, rank=0으로 반환된다") + @DisplayName("동아리 이달의 현황 조회 - 성공: 피드가 없으면 모든 값 0으로 반환된다") @Test void getClubMonthlyStatus_noFeeds() { // given @@ -238,11 +248,12 @@ void getClubMonthlyStatus_noFeeds() { softly.assertThat(result.year()).isEqualTo(2000); softly.assertThat(result.month()).isEqualTo(1); softly.assertThat(result.rank()).isEqualTo(0); - softly.assertThat(result.feedCount()).isEqualTo(0L); - softly.assertThat(result.viewCount()).isEqualTo(0L); - softly.assertThat(result.likeCount()).isEqualTo(0L); - softly.assertThat(result.commentCount()).isEqualTo(0L); - softly.assertThat(result.score()).isEqualTo(0L); + softly.assertThat(result.lastMonthRank()).isEqualTo(0); + softly.assertThat(result.feedScore()).isEqualTo(0L); + softly.assertThat(result.viewScore()).isEqualTo(0L); + softly.assertThat(result.likeScore()).isEqualTo(0L); + softly.assertThat(result.commentScore()).isEqualTo(0L); + softly.assertThat(result.totalScore()).isEqualTo(0L); }); } @@ -265,11 +276,116 @@ void getClubMonthlyStatus_rankAccuracy() { // when — 동아리B 회장이 조회 ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus(userB.getId(), year, month); - // then — 동아리A(score=20) > 동아리B(score=10), 동아리B는 rank=2 + // then — 동아리A(totalScore=20) > 동아리B(totalScore=10), 동아리B는 rank=2 assertSoftly(softly -> { softly.assertThat(result.rank()).isEqualTo(2); - softly.assertThat(result.feedCount()).isEqualTo(1L); - softly.assertThat(result.score()).isEqualTo(10L); + softly.assertThat(result.feedScore()).isEqualTo(10L); + softly.assertThat(result.totalScore()).isEqualTo(10L); + }); + } + + @DisplayName("동아리 이달의 현황 조회 - 성공: 저번 달에 피드가 있으면 lastMonthRank가 반환된다") + @Test + void getClubMonthlyStatus_withLastMonthRank() { + // given + User user = userRepository.save(UserFixture.createClubUser()); + Club club = clubRepository.save(ClubFixture.createClub(user)); + + // 저번 달 피드 생성 + LocalDate lastMonth = LocalDate.now().minusMonths(1); + Feed lastMonthFeed = feedRepository.save(FeedFixture.createImageFeed(club, "저번달 피드")); + jdbcTemplate.update("UPDATE feed SET created_at = ? WHERE id = ?", + Timestamp.valueOf(lastMonth.atStartOfDay()), lastMonthFeed.getId()); + + // 이번 달 피드 생성 + feedRepository.save(FeedFixture.createImageFeed(club, "이번달 피드")); + + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + + // when + ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus(user.getId(), year, month); + + // then — 저번 달에도 피드가 있으므로 lastMonthRank > 0 + assertSoftly(softly -> { + softly.assertThat(result.rank()).isEqualTo(1); + softly.assertThat(result.lastMonthRank()).isEqualTo(1); + }); + } + + @DisplayName("동아리 이달의 현황 조회 - 성공: 저번 달에 피드가 없으면 lastMonthRank는 0이다") + @Test + void getClubMonthlyStatus_noLastMonthFeeds() { + // given + User user = userRepository.save(UserFixture.createClubUser()); + Club club = clubRepository.save(ClubFixture.createClub(user)); + + // 이번 달 피드만 생성 (저번 달 피드 없음) + feedRepository.save(FeedFixture.createImageFeed(club, "이번달 피드")); + + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + + // when + ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus(user.getId(), year, month); + + // then — 저번 달 피드가 없으므로 lastMonthRank = 0 + assertThat(result.lastMonthRank()).isEqualTo(0); + } + + @DisplayName("동아리 이달의 현황 조회 - 성공: 이번 달 피드가 없어도 저번 달 순위가 반환된다") + @Test + void getClubMonthlyStatus_noCurrentMonthFeed_butHasLastMonthRank() { + // given + User user = userRepository.save(UserFixture.createClubUser()); + Club club = clubRepository.save(ClubFixture.createClub(user)); + + // 저번 달 피드만 생성 (이번 달 피드 없음) + LocalDate lastMonth = LocalDate.now().minusMonths(1); + Feed lastMonthFeed = feedRepository.save(FeedFixture.createImageFeed(club, "저번달 피드")); + jdbcTemplate.update("UPDATE feed SET created_at = ? WHERE id = ?", + Timestamp.valueOf(lastMonth.atStartOfDay()), lastMonthFeed.getId()); + + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + + // when + ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus(user.getId(), year, month); + + // then — 이번 달 score=0이어도 저번 달 순위가 정상 반환되어야 한다 + assertSoftly(softly -> { + softly.assertThat(result.rank()).isEqualTo(0); + softly.assertThat(result.lastMonthRank()).isEqualTo(1); + softly.assertThat(result.totalScore()).isEqualTo(0L); + }); + } + + @DisplayName("동아리 이달의 현황 조회 - 성공: 1월 조회 시 전년도 12월 순위가 반환된다") + @Test + void getClubMonthlyStatus_januaryLooksAtDecember() { + // given + User user = userRepository.save(UserFixture.createClubUser()); + Club club = clubRepository.save(ClubFixture.createClub(user)); + + // 전년도 12월 피드 생성 + int currentYear = LocalDate.now().getYear(); + Feed decemberFeed = feedRepository.save(FeedFixture.createImageFeed(club, "12월 피드")); + jdbcTemplate.update("UPDATE feed SET created_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDateTime.of(currentYear - 1, 12, 15, 10, 0)), decemberFeed.getId()); + + // 1월 피드 생성 + Feed januaryFeed = feedRepository.save(FeedFixture.createImageFeed(club, "1월 피드")); + jdbcTemplate.update("UPDATE feed SET created_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDateTime.of(currentYear, 1, 15, 10, 0)), januaryFeed.getId()); + + // when — 1월 조회 + ClubMonthlyStatusQuery result = feedRankingService.getClubMonthlyStatus( + user.getId(), currentYear, 1); + + // then — 전년도 12월 순위가 lastMonthRank로 반환된다 + assertSoftly(softly -> { + softly.assertThat(result.rank()).isEqualTo(1); + softly.assertThat(result.lastMonthRank()).isEqualTo(1); }); } }