From 04604ef89fdfe4ab30568e7d0960c8f150316647 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:06:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/HistoryImageRepository.java | 18 ++++- .../like/controller/LikeController.java | 33 ++++++++ .../dto/response/LikedHistoriesResponse.java | 21 +++++ .../like/repository/MemberLikeRepository.java | 9 ++- .../domain/like/service/LikeService.java | 8 ++ .../domain/like/service/LikeServiceImpl.java | 77 +++++++++++++++++++ 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java index 3d2d6e6f..21196408 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java @@ -1,6 +1,22 @@ package org.clokey.domain.history.repository; +import java.util.List; import org.clokey.history.entity.HistoryImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface HistoryImageRepository extends JpaRepository {} +public interface HistoryImageRepository extends JpaRepository { + @Query( + """ + SELECT hi.history.id, hi.imageUrl + FROM HistoryImage hi + WHERE hi.history.id IN :historyIds + AND hi.id = ( + SELECT MIN(h2.id) + FROM HistoryImage h2 + WHERE h2.history.id = hi.history.id + ) + """) + List getFirstImageUrlsWithHistoryId(@Param("historyIds") List historyIds); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java new file mode 100644 index 00000000..34f551ac --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -0,0 +1,33 @@ +package org.clokey.domain.like.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.awt.print.Pageable; +import lombok.RequiredArgsConstructor; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.response.BaseResponse; +import org.springframework.data.web.PageableDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +@Tag(name = "9. 좋아요 API", description = "좋아요 관련 API입니다.") +@Validated +public class LikeController { + + private final LikeService likeService; + + @GetMapping + @Operation(summary = "좋아요한 기록 조회", description = "사용자가 좋아요한 기록을 조회합니다.") + public BaseResponse getLikedHistories( + @PageableDefault(size = 10) Pageable pageable) { + LikedHistoriesResponse histories = likeService.getLikedHistories(pageable); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, histories); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java new file mode 100644 index 00000000..e5fce94b --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -0,0 +1,21 @@ +package org.clokey.domain.like.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "좋아요 히스토리 조회 결과") +public record LikedHistoriesResponse( + @Schema(description = "히스토리 미리보기 목록") List historyPreviews, + @Schema(description = "전체 페이지 수", example = "1") int totalPage, + @Schema(description = "전체 요소 수", example = "10") long totalElements, + @Schema(description = "첫 페이지 여부", example = "true") boolean isFirst, + @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { + @Schema(description = "히스토리 미리보기 DTO") + public record LikedHistoryPreview( + @Schema(description = "히스토리 ID", example = "30") Long id, + @Schema( + description = "히스토리 대표 이미지 URL", + example = + "https://clokeybucket.s3.ap-northeast-2.amazonaws.com/example.jpg") + String imageUrl) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index a645167e..ecca87d2 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,6 +1,13 @@ package org.clokey.domain.like.repository; +import java.awt.print.Pageable; import org.clokey.like.entity.MemberLike; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface MemberLikeRepository extends JpaRepository {} +public interface MemberLikeRepository extends JpaRepository { + + @Query("SELECT ml FROM MemberLike ml WHERE ml.member.id = :memberId") + Slice findLikedHistoriesByMemberId(Long memberId, Pageable pageable); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java new file mode 100644 index 00000000..518eb1bf --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -0,0 +1,8 @@ +package org.clokey.domain.like.service; + +import java.awt.print.Pageable; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; + +public interface LikeService { + LikedHistoriesResponse getLikedHistories(Pageable pageable); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java new file mode 100644 index 00000000..4f5c5a2d --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -0,0 +1,77 @@ +package org.clokey.domain.like.service; + +import java.awt.print.Pageable; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeServiceImpl implements LikeService { + + private final MemberUtil memberUtil; + private final MemberLikeRepository memberLikeRepository; + private final HistoryImageRepository historyImageRepository; // 이미지 조회용 + + @Override + @Transactional(readOnly = true) + public LikedHistoriesResponse getLikedHistories(Pageable pageable) { + + final Member currentMember = memberUtil.getCurrentMember(); + + // 좋아요 Slice 조회 + Slice likes = + memberLikeRepository.findLikedHistoriesByMemberId(currentMember.getId(), pageable); + + // 모든 historyId 모아서 한 번에 이미지 URL 조회 (N+1 방지) + List historyIds = + likes.getContent().stream().map(like -> like.getHistory().getId()).toList(); + + Map historyImageMap = findFirstImagesByHistoryIds(historyIds); + + // DTO 변환 + List previews = + likes.getContent().stream() + .map( + like -> { + History history = like.getHistory(); + String firstImageUrl = historyImageMap.get(history.getId()); + return new LikedHistoriesResponse.LikedHistoryPreview( + history.getId(), firstImageUrl); + }) + .toList(); + + // 결과 DTO 생성 + return new LikedHistoriesResponse( + previews, + likes.getNumber() + 1, + likes.getNumberOfElements(), + likes.isFirst(), + likes.isLast()); + } + + private Map findFirstImagesByHistoryIds(List historyIds) { + if (historyIds.isEmpty()) { + return Map.of(); + } + + // DB에서 [historyId, firstImageUrl] 형태로 조회 + List rows = historyImageRepository.getFirstImageUrlsWithHistoryId(historyIds); + + return rows.stream() + .collect( + Collectors.toMap( + row -> ((Number) row[0]).longValue(), row -> (String) row[1])); + } +} From 01d54562c28d12bcb1961a34aabd60034d810e40 Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:05:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat=20:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/controller/LikeController.java | 14 +- .../dto/response/LikedHistoriesResponse.java | 6 +- .../like/repository/MemberLikeRepository.java | 2 +- .../domain/like/service/LikeService.java | 5 +- .../domain/like/service/LikeServiceImpl.java | 42 +++--- .../like/controller/LikeControllerTest.java | 107 ++++++++++++++ .../domain/like/service/LikeServiceTest.java | 131 ++++++++++++++++++ 7 files changed, 269 insertions(+), 38 deletions(-) create mode 100644 clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java index 34f551ac..e4602223 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -2,12 +2,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import java.awt.print.Pageable; import lombok.RequiredArgsConstructor; import org.clokey.code.GlobalBaseSuccessCode; import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.service.LikeService; import org.clokey.response.BaseResponse; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -23,11 +24,12 @@ public class LikeController { private final LikeService likeService; - @GetMapping + @GetMapping("/histories") @Operation(summary = "좋아요한 기록 조회", description = "사용자가 좋아요한 기록을 조회합니다.") - public BaseResponse getLikedHistories( - @PageableDefault(size = 10) Pageable pageable) { - LikedHistoriesResponse histories = likeService.getLikedHistories(pageable); - return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, histories); + public BaseResponse> + getLikedHistories(@PageableDefault(size = 10) Pageable pageable) { + SliceResponse response = + likeService.getLikedHistories(pageable); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java index e5fce94b..b567d8b0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -3,13 +3,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -@Schema(description = "좋아요 히스토리 조회 결과") +@Schema(description = "좋아요 히스토리 조회 결과 (Slice 기반)") public record LikedHistoriesResponse( @Schema(description = "히스토리 미리보기 목록") List historyPreviews, - @Schema(description = "전체 페이지 수", example = "1") int totalPage, - @Schema(description = "전체 요소 수", example = "10") long totalElements, - @Schema(description = "첫 페이지 여부", example = "true") boolean isFirst, @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { + @Schema(description = "히스토리 미리보기 DTO") public record LikedHistoryPreview( @Schema(description = "히스토리 ID", example = "30") Long id, diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index ecca87d2..11d1451e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,7 +1,7 @@ package org.clokey.domain.like.repository; -import java.awt.print.Pageable; import org.clokey.like.entity.MemberLike; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java index 518eb1bf..6d9a0f6c 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -1,8 +1,9 @@ package org.clokey.domain.like.service; -import java.awt.print.Pageable; import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.Pageable; public interface LikeService { - LikedHistoriesResponse getLikedHistories(Pageable pageable); + SliceResponse getLikedHistories(Pageable pageable); } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java index 4f5c5a2d..82b8ecbb 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -1,6 +1,5 @@ package org.clokey.domain.like.service; -import java.awt.print.Pageable; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -9,64 +8,57 @@ import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.repository.MemberLikeRepository; import org.clokey.global.util.MemberUtil; -import org.clokey.history.entity.History; import org.clokey.like.entity.MemberLike; import org.clokey.member.entity.Member; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class LikeServiceImpl implements LikeService { private final MemberUtil memberUtil; private final MemberLikeRepository memberLikeRepository; - private final HistoryImageRepository historyImageRepository; // 이미지 조회용 + private final HistoryImageRepository historyImageRepository; @Override @Transactional(readOnly = true) - public LikedHistoriesResponse getLikedHistories(Pageable pageable) { + public SliceResponse getLikedHistories( + Pageable pageable) { final Member currentMember = memberUtil.getCurrentMember(); - // 좋아요 Slice 조회 Slice likes = memberLikeRepository.findLikedHistoriesByMemberId(currentMember.getId(), pageable); - // 모든 historyId 모아서 한 번에 이미지 URL 조회 (N+1 방지) + if (likes == null || likes.getContent().isEmpty()) { + return new SliceResponse<>(List.of(), true); + } + List historyIds = likes.getContent().stream().map(like -> like.getHistory().getId()).toList(); - Map historyImageMap = findFirstImagesByHistoryIds(historyIds); + Map imageMap = findFirstImagesByHistoryIds(historyIds); - // DTO 변환 List previews = likes.getContent().stream() .map( - like -> { - History history = like.getHistory(); - String firstImageUrl = historyImageMap.get(history.getId()); - return new LikedHistoriesResponse.LikedHistoryPreview( - history.getId(), firstImageUrl); - }) + like -> + new LikedHistoriesResponse.LikedHistoryPreview( + like.getHistory().getId(), + imageMap.get(like.getHistory().getId()))) .toList(); - // 결과 DTO 생성 - return new LikedHistoriesResponse( - previews, - likes.getNumber() + 1, - likes.getNumberOfElements(), - likes.isFirst(), - likes.isLast()); + return new SliceResponse<>(previews, likes.isLast()); } private Map findFirstImagesByHistoryIds(List historyIds) { - if (historyIds.isEmpty()) { - return Map.of(); - } + if (historyIds.isEmpty()) return Map.of(); - // DB에서 [historyId, firstImageUrl] 형태로 조회 List rows = historyImageRepository.getFirstImageUrlsWithHistoryId(historyIds); return rows.stream() diff --git a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java new file mode 100644 index 00000000..900f990b --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java @@ -0,0 +1,107 @@ +package org.clokey.domain.like.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.Nested; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(LikeController.class) +@AutoConfigureMockMvc(addFilters = false) +public class LikeControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private LikeService likeService; + + @Nested + class 좋아요한_기록_조회_시 { + @Test + void 유효한_요청이면_좋아요한_기록을_반환한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, true); + + given(likeService.getLikedHistories(any(Pageable.class))).willReturn(sliceResponse); + + ResultActions perform = + mockMvc.perform( + get("/likes/histories") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(true)); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_응답한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, false); + + given(likeService.getLikedHistories(any(Pageable.class))).willReturn(sliceResponse); + + // when + ResultActions perform = + mockMvc.perform( + get("/likes/histories").contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(false)); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java new file mode 100644 index 00000000..e909bdef --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -0,0 +1,131 @@ +package org.clokey.domain.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.TransactionUtil; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.Situation; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class LikeServiceTest extends IntegrationTest { + + @Autowired private LikeService likeService; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private HistoryImageRepository historyImageRepository; + + @Autowired private TransactionUtil transactionUtil; + @MockitoBean private MemberUtil memberUtil; + + @Nested + class 좋아요한_기록을_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content1", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + History history2 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content2", + memberRepository.findById(2L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + historyRepository.saveAll(List.of(history1, history2)); + + HistoryImage historyImage1 = + HistoryImage.createHistoryImage("http://image1.url", history1); + HistoryImage historyImage2 = + HistoryImage.createHistoryImage("http://image2.url", history2); + historyImageRepository.saveAll(List.of(historyImage1, historyImage2)); + } + + @Test + void 좋아요한_기록이_있으면_기록을_반환한다() { + // given + memberLikeRepository.saveAll( + List.of( + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(1L).orElseThrow()), + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(2L).orElseThrow()))); + + // when + SliceResponse response = + likeService.getLikedHistories( + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.isLast()).isTrue(); + + response.content() + .forEach( + preview -> { + assertThat(preview.id()).isNotNull(); + assertThat(preview.imageUrl()).isNotNull(); + }); + } + + @Test + void 좋아요한_기록이_없으면_빈_리스트를_반환한다() { + // when + SliceResponse response = + likeService.getLikedHistories( + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.isLast()).isTrue(); + } + } +} From 0bb0fb1afcfa7cb11deaea5282475ccfa42f80de Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:53:21 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor=20:=20=ED=8E=98=EC=9D=B4=EC=A7=95?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/controller/LikeController.java | 14 ++++++--- .../like/repository/MemberLikeRepository.java | 14 +++++++-- .../domain/like/service/LikeService.java | 4 +-- .../domain/like/service/LikeServiceImpl.java | 30 ++++++++++++------- .../like/controller/LikeControllerTest.java | 11 +++---- .../domain/like/service/LikeServiceTest.java | 8 ++--- 6 files changed, 50 insertions(+), 31 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java index e4602223..0c18eed5 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -1,18 +1,19 @@ package org.clokey.domain.like.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.clokey.code.GlobalBaseSuccessCode; import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.service.LikeService; +import org.clokey.global.annotation.PageSize; import org.clokey.response.BaseResponse; import org.clokey.response.SliceResponse; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -27,9 +28,14 @@ public class LikeController { @GetMapping("/histories") @Operation(summary = "좋아요한 기록 조회", description = "사용자가 좋아요한 기록을 조회합니다.") public BaseResponse> - getLikedHistories(@PageableDefault(size = 10) Pageable pageable) { + getLikedHistories( + @Parameter(description = "이전 페이지의 좋아요 ID (첫 요청 시 생략)") + @RequestParam(required = false) + Long lastLikeId, + @Parameter(description = "페이지당 조회할 개수") @RequestParam @PageSize Integer size) { SliceResponse response = - likeService.getLikedHistories(pageable); + likeService.getLikedHistories(lastLikeId, size); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index 11d1451e..0dc9520f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,13 +1,21 @@ package org.clokey.domain.like.repository; +import java.util.List; import org.clokey.like.entity.MemberLike; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; public interface MemberLikeRepository extends JpaRepository { - @Query("SELECT ml FROM MemberLike ml WHERE ml.member.id = :memberId") - Slice findLikedHistoriesByMemberId(Long memberId, Pageable pageable); + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.member.id = :memberId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikedHistoriesByMemberId( + Long memberId, Long lastLikeId, Pageable pageable); } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java index 6d9a0f6c..53f1ecf4 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -2,8 +2,8 @@ import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.response.SliceResponse; -import org.springframework.data.domain.Pageable; public interface LikeService { - SliceResponse getLikedHistories(Pageable pageable); + SliceResponse getLikedHistories( + Long lastLikedId, Integer size); } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java index 82b8ecbb..e60465b7 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -11,8 +11,8 @@ import org.clokey.like.entity.MemberLike; import org.clokey.member.entity.Member; import org.clokey.response.SliceResponse; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,26 +26,34 @@ public class LikeServiceImpl implements LikeService { private final HistoryImageRepository historyImageRepository; @Override - @Transactional(readOnly = true) public SliceResponse getLikedHistories( - Pageable pageable) { + Long lastLikeId, Integer size) { - final Member currentMember = memberUtil.getCurrentMember(); + Member currentMember = memberUtil.getCurrentMember(); - Slice likes = - memberLikeRepository.findLikedHistoriesByMemberId(currentMember.getId(), pageable); + // limit + 1 조회 + Pageable pageable = PageRequest.of(0, size + 1); - if (likes == null || likes.getContent().isEmpty()) { + List likes = + memberLikeRepository.findLikedHistoriesByMemberId( + currentMember.getId(), lastLikeId, pageable); + + boolean isLast = likes.size() <= size; + + if (!isLast) { + likes = likes.subList(0, size); + } + + if (likes.isEmpty()) { return new SliceResponse<>(List.of(), true); } - List historyIds = - likes.getContent().stream().map(like -> like.getHistory().getId()).toList(); + List historyIds = likes.stream().map(like -> like.getHistory().getId()).toList(); Map imageMap = findFirstImagesByHistoryIds(historyIds); List previews = - likes.getContent().stream() + likes.stream() .map( like -> new LikedHistoriesResponse.LikedHistoryPreview( @@ -53,7 +61,7 @@ public SliceResponse getLikedHistori imageMap.get(like.getHistory().getId()))) .toList(); - return new SliceResponse<>(previews, likes.isLast()); + return new SliceResponse<>(previews, isLast); } private Map findFirstImagesByHistoryIds(List historyIds) { diff --git a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java index 900f990b..47253c99 100644 --- a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java @@ -1,6 +1,7 @@ package org.clokey.domain.like.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -16,7 +17,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -45,12 +45,11 @@ class 좋아요한_기록_조회_시 { SliceResponse sliceResponse = new SliceResponse<>(previews, true); - given(likeService.getLikedHistories(any(Pageable.class))).willReturn(sliceResponse); + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); ResultActions perform = mockMvc.perform( get("/likes/histories") - .param("page", "0") .param("size", "10") .contentType(MediaType.APPLICATION_JSON)); @@ -82,12 +81,14 @@ class 좋아요한_기록_조회_시 { SliceResponse sliceResponse = new SliceResponse<>(previews, false); - given(likeService.getLikedHistories(any(Pageable.class))).willReturn(sliceResponse); + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); // when ResultActions perform = mockMvc.perform( - get("/likes/histories").contentType(MediaType.APPLICATION_JSON)); + get("/likes/histories") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); // then perform.andExpect(status().isOk()) diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java index e909bdef..917dedf5 100644 --- a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -26,8 +26,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.test.context.bean.override.mockito.MockitoBean; public class LikeServiceTest extends IntegrationTest { @@ -101,8 +99,7 @@ void setUp() { // when SliceResponse response = - likeService.getLikedHistories( - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + likeService.getLikedHistories(null, 10); // then assertThat(response.content()).hasSize(2); @@ -120,8 +117,7 @@ void setUp() { void 좋아요한_기록이_없으면_빈_리스트를_반환한다() { // when SliceResponse response = - likeService.getLikedHistories( - PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"))); + likeService.getLikedHistories(null, 10); // then assertThat(response.content()).isEmpty(); From d00dd5ced7d903c1bebabebc87895270a871490f Mon Sep 17 00:00:00 2001 From: paeng <127924700+juuuuone@users.noreply.github.com> Date: Mon, 22 Dec 2025 00:50:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix=20:=20test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/like/service/LikeServiceTest.java | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java index 917dedf5..d47693c3 100644 --- a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -52,13 +52,7 @@ void setUp() { "testNickName1", OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); - Member member2 = - Member.createMember( - "testEmail2", - "testClokeyId2", - "testNickName2", - OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); - memberRepository.saveAll(List.of(member1, member2)); + memberRepository.saveAll(List.of(member1)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation1 = Situation.createSituation("testSituation1"); @@ -72,9 +66,9 @@ void setUp() { situationRepository.findById(1L).orElseThrow()); History history2 = History.createHistory( - LocalDate.of(2024, 12, 25), + LocalDate.of(2024, 12, 26), "content2", - memberRepository.findById(2L).orElseThrow(), + memberRepository.findById(1L).orElseThrow(), situationRepository.findById(1L).orElseThrow()); historyRepository.saveAll(List.of(history1, history2)); @@ -105,12 +99,10 @@ void setUp() { assertThat(response.content()).hasSize(2); assertThat(response.isLast()).isTrue(); - response.content() - .forEach( - preview -> { - assertThat(preview.id()).isNotNull(); - assertThat(preview.imageUrl()).isNotNull(); - }); + assertThat(response.content()) + .extracting("id", "imageUrl") + .containsExactly( + tuple(2L, "http://image2.url"), tuple(1L, "http://image1.url")); } @Test