From b0c1400dd57a1739bb5006104ff7a6c9838ea0d8 Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Sun, 8 Nov 2026 23:15:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?#626=20[Feat]=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=B2=A0=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EC=A0=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직전 한 주(월~일, KST)에 눌린 좋아요 수로 일반 배틀 관점 최대 25개 조회 - 사후투표 완료 여부를 participated 로 내려 앱이 이동 경로를 결정 - 토큰 선택 경로를 추가해 비로그인도 조회 가능, 잘못된 토큰은 401 유지 Co-Authored-By: Claude Opus 5.5 --- docs/api-specs/perspectives-api.md | 33 +++ .../picke/domain/oauth/jwt/JwtFilter.java | 10 +- .../controller/PerspectiveController.java | 10 + .../WeeklyBestPerspectiveResponse.java | 19 ++ .../repository/PerspectiveLikeRepository.java | 24 ++ .../repository/PerspectiveRepository.java | 3 + .../WeeklyPerspectiveLikeCount.java | 8 + .../service/WeeklyBestPerspectiveService.java | 88 +++++++ .../user/repository/UserBattleRepository.java | 12 + .../picke/global/config/SecurityConfig.java | 1 + ...eklyBestPerspectiveApiIntegrationTest.java | 242 ++++++++++++++++++ 11 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java create mode 100644 src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java create mode 100644 src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java diff --git a/docs/api-specs/perspectives-api.md b/docs/api-specs/perspectives-api.md index ec976cd7..e01aeaa6 100644 --- a/docs/api-specs/perspectives-api.md +++ b/docs/api-specs/perspectives-api.md @@ -147,6 +147,39 @@ } ``` +--- +## 주간 베스트 관점 조회 API +### `GET /api/v1/perspectives/weekly-best` + +- 직전 완료된 한 주(월요일 00:00 ~ 일요일 24:00, KST) 동안 눌린 좋아요 수 기준으로 최대 25개를 반환합니다. 화면의 5개씩 페이지 처리는 앱에서 합니다. +- 정렬: 해당 주 좋아요 수 내림차순 → 최신 관점 → id 내림차순 +- 대상: `PUBLISHED` 관점 중 공개 배틀의 일반 관점. 클래스 관점은 제외합니다. +- 토큰은 선택입니다. 토큰이 없으면 `participated` 는 모두 `false` 이고, 만료된 토큰을 보내면 401 입니다. +- `participated` 는 사후투표 완료 여부입니다. `true` 면 해당 관점으로, `false` 면 배틀 진입점으로 이동합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "weekStart": "2026-11-02", + "weekEnd": "2026-11-08", + "items": [ + { + "perspectiveId": 1, + "battleId": 10, + "battleTitle": "배틀 제목", + "content": "관점 본문", + "likeCount": 50, + "participated": true + } + ] + }, + "error": null +} +``` + --- ## 내 PENDING 관점 조회 API ### `GET /api/v1/battles/{battle_id}/perspectives/me/pending` diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index b71973cf..0ed0fff6 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -84,6 +84,14 @@ public JwtFilter(JwtProvider jwtProvider, UserDailyActivityRepository userDailyA "/error" ); + /** + * 토큰이 없어도 통과하지만, 토큰이 있으면 검증해 사용자를 식별하는 경로. + * WHITELIST는 필터 자체를 건너뛰어 로그인 사용자도 식별되지 않으므로 분리한다. + */ + private static final Set OPTIONAL_AUTH_PATHS = Set.of( + "/api/v1/perspectives/weekly-best" + ); + @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String requestUri = request.getRequestURI(); @@ -127,7 +135,7 @@ protected void doFilterInternal(HttpServletRequest request, SecurityContextHolder.getContext().setAuthentication(authentication); recordActivity(userId); - } else { + } else if (!OPTIONAL_AUTH_PATHS.contains(requestUri)) { // shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단 log.warn("[JwtFilter] Token missing for secured URI: {}", requestUri); setErrorResponse(response, ErrorCode.AUTH_UNAUTHORIZED); diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 842cc265..1ade2e1f 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -7,7 +7,9 @@ import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; import com.swyp.picke.domain.perspective.dto.response.PerspectiveListResponse; import com.swyp.picke.domain.perspective.dto.response.UpdatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse; import com.swyp.picke.domain.perspective.service.PerspectiveService; +import com.swyp.picke.domain.perspective.service.WeeklyBestPerspectiveService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -33,6 +35,14 @@ public class PerspectiveController { private final PerspectiveService perspectiveService; + private final WeeklyBestPerspectiveService weeklyBestPerspectiveService; + + @Operation(summary = "주간 베스트 관점 조회", description = "직전 한 주 동안 좋아요를 많이 받은 관점을 최대 25개 조회합니다. 토큰 없이 호출하면 participated 는 false 입니다.") + @GetMapping("/perspectives/weekly-best") + public ApiResponse getWeeklyBestPerspectives( + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(weeklyBestPerspectiveService.getWeeklyBest(userId)); + } @Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.") @GetMapping("/perspectives/{perspectiveId}") diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java new file mode 100644 index 00000000..256520d0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDate; +import java.util.List; + +public record WeeklyBestPerspectiveResponse( + LocalDate weekStart, + LocalDate weekEnd, + List items +) { + public record Item( + Long perspectiveId, + Long battleId, + String battleTitle, + String content, + long likeCount, + boolean participated + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java index 9764b4a0..d608e15c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java @@ -1,13 +1,17 @@ package com.swyp.picke.domain.perspective.repository; +import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -33,5 +37,25 @@ select count(l) from PerspectiveLike l long countReceivedByUserInClass(@org.springframework.data.repository.query.Param("classId") Long classId, @org.springframework.data.repository.query.Param("userId") Long userId); + // 주간 베스트 관점: 기간 안에 눌린 좋아요 수 기준. 취소된 좋아요는 행이 삭제되어 자동으로 빠진다 + @Query(""" + select p.id as perspectiveId, count(l) as likeCount + from PerspectiveLike l + join l.perspective p + join p.battle b + where l.createdAt >= :start + and l.createdAt < :end + and p.classroom is null + and p.status = :perspectiveStatus + and b.status = :battleStatus + group by p.id, p.createdAt + order by count(l) desc, p.createdAt desc, p.id desc + """) + List findWeeklyLikeRanking(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("perspectiveStatus") PerspectiveStatus perspectiveStatus, + @Param("battleStatus") BattleStatus battleStatus, + Pageable pageable); + void deleteAllByPerspective(Perspective perspective); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java index bb58c20b..686d0d74 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java @@ -51,4 +51,7 @@ public interface PerspectiveRepository extends JpaRepository // 베댓 보상 배치: 일반 배틀 의견만 List findByBattleIdAndClassroomIsNullAndStatusOrderByLikeCountDescCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); + + @Query("select p from Perspective p join fetch p.battle where p.id in :ids") + List findAllWithBattleByIdIn(@Param("ids") List ids); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java b/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java new file mode 100644 index 00000000..858ad7ae --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.perspective.repository.projection; + +public interface WeeklyPerspectiveLikeCount { + + Long getPerspectiveId(); + + long getLikeCount(); +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java new file mode 100644 index 00000000..07ca21d0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java @@ -0,0 +1,88 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.repository.UserBattleRepository; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 직전 완료된 한 주(월~일, KST) 동안 좋아요를 많이 받은 일반 배틀 관점을 조회한다. + * 집계 테이블 없이 기간을 고정해 계산하므로 숨김 처리된 관점은 바로 빠진다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WeeklyBestPerspectiveService { + + private static final int MAX_ITEMS = 25; + + private final PerspectiveLikeRepository perspectiveLikeRepository; + private final PerspectiveRepository perspectiveRepository; + private final UserBattleRepository userBattleRepository; + private final Clock clock; + + public WeeklyBestPerspectiveResponse getWeeklyBest(Long userId) { + LocalDate thisMonday = LocalDate.now(clock).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekStart = thisMonday.minusWeeks(1); + + List ranking = perspectiveLikeRepository.findWeeklyLikeRanking( + weekStart.atStartOfDay(), + thisMonday.atStartOfDay(), + PerspectiveStatus.PUBLISHED, + BattleStatus.PUBLISHED, + PageRequest.of(0, MAX_ITEMS)); + if (ranking.isEmpty()) { + return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), List.of()); + } + + Map perspectives = perspectiveRepository + .findAllWithBattleByIdIn(ranking.stream().map(WeeklyPerspectiveLikeCount::getPerspectiveId).toList()) + .stream() + .collect(Collectors.toMap(Perspective::getId, Function.identity())); + Set participatedBattleIds = findParticipatedBattleIds(userId, perspectives.values()); + + List items = ranking.stream() + .map(rank -> { + Perspective perspective = perspectives.get(rank.getPerspectiveId()); + Long battleId = perspective.getBattle().getId(); + return new WeeklyBestPerspectiveResponse.Item( + perspective.getId(), + battleId, + perspective.getBattle().getTitle(), + perspective.getContent(), + rank.getLikeCount(), + participatedBattleIds.contains(battleId)); + }) + .toList(); + return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), items); + } + + // 참여 기준은 관점 작성 조건과 같은 사후투표 완료(COMPLETED)다. 비로그인은 미참여로 본다. + private Set findParticipatedBattleIds(Long userId, java.util.Collection perspectives) { + if (userId == null) { + return Set.of(); + } + Set battleIds = perspectives.stream() + .map(perspective -> perspective.getBattle().getId()) + .collect(Collectors.toSet()); + return userBattleRepository.findBattleIdsByUserAndStep(userId, battleIds, UserBattleStep.COMPLETED); + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java index 30e98539..de392128 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java @@ -27,4 +27,16 @@ Set findClassroomIdsWithActivity(@Param("userId") Long userId, // 클래스 결과 집계·리포트 대상 선별: 이 클래스 안의 진행 기록 전체 (user 는 LAZY) List findByClassroomId(Long classroomId); + + // 일반 배틀 진행 단계가 step 인 배틀만 추린다 + @Query(""" + select ub.battle.id from UserBattle ub + where ub.user.id = :userId + and ub.classroom is null + and ub.step = :step + and ub.battle.id in :battleIds + """) + Set findBattleIdsByUserAndStep(@Param("userId") Long userId, + @Param("battleIds") Collection battleIds, + @Param("step") com.swyp.picke.domain.user.enums.UserBattleStep step); } diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 70507baf..1778d86d 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -72,6 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 3. 단순 조회성 및 진행상태 업데이트 REST API .requestMatchers(HttpMethod.GET, "/api/v1/share/recap/*").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/perspectives/weekly-best").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/tags", "/api/v1/battles/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/v1/battles/**").authenticated() diff --git a/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java new file mode 100644 index 00000000..2d737176 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java @@ -0,0 +1,242 @@ +package com.swyp.picke.domain.perspective.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserBattle; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserBattleRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 주간 베스트 관점(#626)을 HTTP 로 검증한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class WeeklyBestPerspectiveApiIntegrationTest { + + private static final String URL = "/api/v1/perspectives/weekly-best"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtProvider jwtProvider; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private UserRepository userRepository; + @Autowired private UserBattleRepository userBattleRepository; + @Autowired private BattleRepository battleRepository; + @Autowired private BattleOptionRepository battleOptionRepository; + @Autowired private PerspectiveRepository perspectiveRepository; + @Autowired private PerspectiveLikeRepository perspectiveLikeRepository; + + private final List userBattleIds = new ArrayList<>(); + private final List likeIds = new ArrayList<>(); + private final List perspectiveIds = new ArrayList<>(); + private final List optionIds = new ArrayList<>(); + private final List battleIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private LocalDateTime weekStart; + private LocalDateTime weekEnd; + private User viewer; + private User liker1; + private User liker2; + + @BeforeEach + void setUp() { + LocalDate thisMonday = LocalDate.now(ZoneId.of("Asia/Seoul")) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + weekStart = thisMonday.minusWeeks(1).atStartOfDay(); + weekEnd = thisMonday.atStartOfDay(); + viewer = newUser(); + liker1 = newUser(); + liker2 = newUser(); + } + + @AfterEach + void tearDown() { + perspectiveLikeRepository.deleteAllById(likeIds); + perspectiveRepository.deleteAllById(perspectiveIds); + userBattleRepository.deleteAllById(userBattleIds); + battleOptionRepository.deleteAllById(optionIds); + battleRepository.deleteAllById(battleIds); + userRepository.deleteAllById(userIds); + } + + @Test + @DisplayName("직전 주에 눌린 좋아요 수로 정렬하고, 기간 밖 좋아요와 비공개 대상은 뺀다") + void ranksByLikesInPreviousWeek() throws Exception { + Battle battle = battle(BattleStatus.PUBLISHED); + Perspective second = perspective(battle); + like(second, liker1, weekStart); + like(second, liker2, weekEnd.minusDays(8)); // 지지난 주 + Perspective first = perspective(battle); + like(first, liker1, weekEnd.minusSeconds(1)); + like(first, liker2, weekStart.plusDays(2)); + Perspective onlyThisWeek = perspective(battle); + like(onlyThisWeek, liker1, weekEnd); + Perspective hidden = perspective(battle); + hidden.hide(); + perspectiveRepository.save(hidden); + like(hidden, liker1, weekStart.plusDays(1)); + Perspective archivedBattle = perspective(battle(BattleStatus.ARCHIVED)); + like(archivedBattle, liker1, weekStart.plusDays(1)); + + JsonNode data = fetch(tokenOf(viewer)); + + assertThat(data.get("weekStart").asText()).isEqualTo(weekStart.toLocalDate().toString()); + assertThat(data.get("weekEnd").asText()).isEqualTo(weekEnd.toLocalDate().minusDays(1).toString()); + List items = ownItems(data); + assertThat(items).extracting(item -> item.get("perspectiveId").asLong()) + .containsExactly(first.getId(), second.getId()); + assertThat(items).extracting(item -> item.get("likeCount").asLong()).containsExactly(2L, 1L); + assertThat(items.get(0).get("battleId").asLong()).isEqualTo(battle.getId()); + assertThat(items.get(0).get("battleTitle").asText()).isEqualTo(battle.getTitle()); + assertThat(items.get(0).get("content").asText()).isEqualTo("관점 본문"); + } + + @Test + @DisplayName("사후투표까지 완료한 배틀만 participated 가 true 다") + void participatedOnlyAfterPostVote() throws Exception { + Perspective completed = perspective(battle(BattleStatus.PUBLISHED)); + Perspective preVoteOnly = perspective(battle(BattleStatus.PUBLISHED)); + Perspective notJoined = perspective(battle(BattleStatus.PUBLISHED)); + like(completed, liker1, weekStart.plusDays(1)); + like(preVoteOnly, liker1, weekStart.plusDays(1)); + like(notJoined, liker1, weekStart.plusDays(1)); + step(completed.getBattle(), UserBattleStep.COMPLETED); + step(preVoteOnly.getBattle(), UserBattleStep.TTS_LISTENING); + + List items = ownItems(fetch(tokenOf(viewer))); + + assertThat(participated(items, completed)).isTrue(); + assertThat(participated(items, preVoteOnly)).isFalse(); + assertThat(participated(items, notJoined)).isFalse(); + } + + @Test + @DisplayName("토큰 없이도 목록을 보고 participated 는 false 다. 잘못된 토큰은 401 이다") + void tokenIsOptionalButValidated() throws Exception { + Perspective perspective = perspective(battle(BattleStatus.PUBLISHED)); + like(perspective, liker1, weekStart.plusDays(1)); + step(perspective.getBattle(), UserBattleStep.COMPLETED); + + List items = ownItems(fetch(null)); + + assertThat(items).hasSize(1); + assertThat(participated(items, perspective)).isFalse(); + mockMvc.perform(get(URL).header("Authorization", "Bearer invalid-token")) + .andExpect(status().isUnauthorized()); + } + + private JsonNode fetch(String token) throws Exception { + var request = get(URL); + if (token != null) { + request.header("Authorization", "Bearer " + token); + } + String body = mockMvc.perform(request) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + return objectMapper.readTree(body).get("data"); + } + + private List ownItems(JsonNode data) { + List items = new ArrayList<>(); + data.get("items").forEach(item -> { + if (perspectiveIds.contains(item.get("perspectiveId").asLong())) { + items.add(item); + } + }); + return items; + } + + private boolean participated(List items, Perspective perspective) { + return items.stream() + .filter(item -> item.get("perspectiveId").asLong() == perspective.getId()) + .findFirst().orElseThrow() + .get("participated").asBoolean(); + } + + private void step(Battle battle, UserBattleStep step) { + userBattleIds.add(userBattleRepository.save(UserBattle.builder() + .user(viewer).battle(battle).step(step).build()).getId()); + } + + private void like(Perspective perspective, User user, LocalDateTime createdAt) { + PerspectiveLike like = perspectiveLikeRepository.save(PerspectiveLike.builder() + .perspective(perspective).user(user).build()); + likeIds.add(like.getId()); + jdbcTemplate.update("update perspective_likes set created_at = ? where id = ?", createdAt, like.getId()); + } + + private Perspective perspective(Battle battle) { + BattleOption option = battleOptionRepository.findByBattle(battle).get(0); + Perspective perspective = Perspective.builder() + .battle(battle).user(liker1).option(option).content("관점 본문").build(); + perspective.publish(); + Perspective saved = perspectiveRepository.save(perspective); + perspectiveIds.add(saved.getId()); + return saved; + } + + private Battle battle(BattleStatus status) { + String title = "주간베스트-" + UUID.randomUUID().toString().substring(0, 8); + Battle battle = battleRepository.save(Battle.builder() + .title(title).summary("요약").description("설명").audioDuration(300) + .status(status).creatorType(BattleCreatorType.ADMIN).build()); + battleIds.add(battle.getId()); + optionIds.add(battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("찬성").stance("근거").representative("칸트").displayOrder(1).build()).getId()); + return battle; + } + + private User newUser() { + User user = userRepository.save(User.builder() + .userTag("wb-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("주간베스트") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build()); + userIds.add(user.getId()); + return user; + } + + private String tokenOf(User user) { + return jwtProvider.createAccessToken(user.getId(), UserRole.USER.name()); + } +} From c6f73b2be1590a597883ec3585958b017850d048 Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Sun, 8 Nov 2026 23:25:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?#626=20[Fix]=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=B2=A0=EC=8A=A4=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=EC=8B=9C=EA=B0=84=EB=8C=80=20=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JdbcTemplate 입력은 hibernate.jdbc.time_zone 변환을 거치지 않아 JVM 시간대가 UTC인 CI에서 집계 기간 경계가 어긋났다. 좋아요 시각도 JPQL로 넣어 조회와 같은 변환을 타게 한다. Co-Authored-By: Claude Opus 5.5 --- .../WeeklyBestPerspectiveApiIntegrationTest.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java index 2d737176..c7d4a1ef 100644 --- a/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java @@ -24,6 +24,7 @@ import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.UserBattleRepository; import com.swyp.picke.domain.user.repository.UserRepository; +import jakarta.persistence.EntityManager; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; @@ -39,9 +40,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.support.TransactionTemplate; /** * 주간 베스트 관점(#626)을 HTTP 로 검증한다. @@ -56,7 +57,8 @@ class WeeklyBestPerspectiveApiIntegrationTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private JwtProvider jwtProvider; - @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private EntityManager entityManager; + @Autowired private TransactionTemplate transactionTemplate; @Autowired private UserRepository userRepository; @Autowired private UserBattleRepository userBattleRepository; @Autowired private BattleRepository battleRepository; @@ -201,7 +203,12 @@ private void like(Perspective perspective, User user, LocalDateTime createdAt) { PerspectiveLike like = perspectiveLikeRepository.save(PerspectiveLike.builder() .perspective(perspective).user(user).build()); likeIds.add(like.getId()); - jdbcTemplate.update("update perspective_likes set created_at = ? where id = ?", createdAt, like.getId()); + // JdbcTemplate 은 hibernate.jdbc.time_zone 변환을 거치지 않아 JVM 시간대가 KST 가 아니면 조회 조건과 어긋난다 + transactionTemplate.executeWithoutResult(status -> entityManager + .createQuery("update PerspectiveLike l set l.createdAt = :createdAt where l.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", like.getId()) + .executeUpdate()); } private Perspective perspective(Battle battle) {