diff --git a/src/main/java/eatda/controller/story/StoriesDetailResponse.java b/src/main/java/eatda/controller/story/StoriesDetailResponse.java new file mode 100644 index 00000000..048f782e --- /dev/null +++ b/src/main/java/eatda/controller/story/StoriesDetailResponse.java @@ -0,0 +1,24 @@ +package eatda.controller.story; + +import eatda.domain.story.Story; +import java.util.List; + +public record StoriesDetailResponse(List stories) { + + public record StoryDetailResponse( + long storyId, + String imageUrl, + long memberId, + String memberNickname + ) { + + public StoryDetailResponse(Story story, String imageUrl) { + this( + story.getId(), + imageUrl, + story.getMember().getId(), + story.getMember().getNickname() + ); + } + } +} diff --git a/src/main/java/eatda/controller/story/StoryController.java b/src/main/java/eatda/controller/story/StoryController.java index d799677d..50a64828 100644 --- a/src/main/java/eatda/controller/story/StoryController.java +++ b/src/main/java/eatda/controller/story/StoryController.java @@ -42,4 +42,13 @@ public ResponseEntity getStory(@PathVariable long storyId) { return ResponseEntity.status(HttpStatus.OK) .body(storyService.getStory(storyId)); } + + @GetMapping("/api/stories/kakao/{kakaoId}") + public ResponseEntity getStoriesByKakaoId( + @PathVariable String kakaoId, + @RequestParam(defaultValue = "5") @Min(1) @Max(50) int size + ) { + return ResponseEntity.status(HttpStatus.OK) + .body(storyService.getPagedStoryDetails(kakaoId, size)); + } } diff --git a/src/main/java/eatda/repository/story/StoryRepository.java b/src/main/java/eatda/repository/story/StoryRepository.java index f6d71bfe..8ddc602b 100644 --- a/src/main/java/eatda/repository/story/StoryRepository.java +++ b/src/main/java/eatda/repository/story/StoryRepository.java @@ -8,4 +8,6 @@ public interface StoryRepository extends JpaRepository { Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + Page findAllByStoreKakaoIdOrderByCreatedAtDesc(String storeKakaoId, Pageable pageable); } diff --git a/src/main/java/eatda/service/story/StoryService.java b/src/main/java/eatda/service/story/StoryService.java index caebd506..f910cb35 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -3,6 +3,7 @@ import eatda.client.map.MapClient; import eatda.client.map.StoreSearchResult; import eatda.controller.story.FilteredSearchResult; +import eatda.controller.story.StoriesDetailResponse; import eatda.controller.story.StoriesResponse; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; @@ -113,4 +114,17 @@ public StoryResponse getStory(long storyId) { story.getMember().getNickname() ); } + + @Transactional(readOnly = true) + public StoriesDetailResponse getPagedStoryDetails(String kakaoId, int size) { + List stories = storyRepository + .findAllByStoreKakaoIdOrderByCreatedAtDesc(kakaoId, PageRequest.of(PAGE_START_NUMBER, size)) + .getContent(); + + List responses = stories.stream() + .map(story -> new StoriesDetailResponse.StoryDetailResponse( + story, imageStorage.getPreSignedUrl(story.getImageKey()))) + .toList(); // TODO: N+1 문제 해결 + return new StoriesDetailResponse(responses); + } } diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java index 8084412b..b4ca8ce7 100644 --- a/src/test/java/eatda/controller/story/StoryControllerTest.java +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.doThrow; import eatda.controller.BaseControllerTest; +import eatda.controller.story.StoriesDetailResponse.StoryDetailResponse; import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import eatda.util.ImageUtils; @@ -139,4 +140,38 @@ class GetStory { .body("message", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getMessage())); } } + + @Nested + class GetPagedStoryDetailsByKakaoId { + + @Test + void 카카오ID로_스토리_목록을_조회할_수_있다() { + String kakaoId = "123456"; + List mockDetails = List.of( + new StoryDetailResponse(1L, "https://s3.bucket.com/story/dummy/1.jpg", 5L, "커찬"), + new StoryDetailResponse(2L, "https://s3.bucket.com/story/dummy/2.jpg", 2L, "지민") + ); + + doReturn(new StoriesDetailResponse(mockDetails)) + .when(storyService) + .getPagedStoryDetails(kakaoId, 5); + + StoriesDetailResponse response = given() + .pathParam("kakaoId", kakaoId) + .queryParam("size", 5) + .when() + .get("/api/stories/kakao/{kakaoId}") + .then().statusCode(200) + .extract().as(StoriesDetailResponse.class); + + assertAll( + () -> assertThat(response.stories()).hasSize(2), + () -> assertThat(response.stories().getFirst().storyId()).isEqualTo(1L), + () -> assertThat(response.stories().getFirst().imageUrl()).isEqualTo( + "https://s3.bucket.com/story/dummy/1.jpg"), + () -> assertThat(response.stories().getFirst().memberId()).isEqualTo(5L), + () -> assertThat(response.stories().getFirst().memberNickname()).isEqualTo("커찬") + ); + } + } } diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java index a9169935..eebdf47e 100644 --- a/src/test/java/eatda/document/story/StoryDocumentTest.java +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -5,12 +5,15 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import eatda.controller.story.StoriesDetailResponse; +import eatda.controller.story.StoriesDetailResponse.StoryDetailResponse; import eatda.controller.story.StoriesResponse; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; @@ -28,6 +31,8 @@ import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.http.HttpHeaders; import org.springframework.restdocs.restassured.RestDocumentationFilter; @@ -227,4 +232,68 @@ class GetStory { .body("message", equalTo(BusinessErrorCode.STORY_NOT_FOUND.getMessage())); } } + + @Nested + class GetStoriesByKakaoId { + + RestDocsRequest requestDocument = request() + .tag(Tag.STORY_API) + .summary("카카오 ID로 스토리 목록 조회") + .description("특정 카카오 ID에 해당하는 스토리 목록을 페이지네이션하여 조회합니다.") + .pathParameter( + parameterWithName("kakaoId").description("가게의 카카오 ID") + ) + .queryParameter( + parameterWithName("size").description("스토리 개수 (기본값: 5) (최소값: 1, 최대값: 50)").optional() + ); + + RestDocsResponse responseDocument = response() + .responseBodyField( + fieldWithPath("stories").type(ARRAY).description("스토리 상세 리스트"), + fieldWithPath("stories[].storyId").type(NUMBER).description("스토리 ID"), + fieldWithPath("stories[].imageUrl").type(STRING).description("스토리 이미지 URL"), + fieldWithPath("stories[].memberId").type(NUMBER).description("회원 ID"), + fieldWithPath("stories[].memberNickname").type(STRING).description("회원 닉네임") + ); + + @Test + void 카카오_ID로_스토리_목록_조회_성공() { + String kakaoId = "123456"; + int size = 5; + StoriesDetailResponse response = new StoriesDetailResponse(List.of( + new StoryDetailResponse(1L, "https://dummy-s3.com/story1.png", 1L, "커찬"), + new StoryDetailResponse(2L, "https://dummy-s3.com/story2.png", 2L, "준환") + )); + doReturn(response).when(storyService).getPagedStoryDetails(kakaoId, size); + + var document = document("story/get-stories-by-kakao-id", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .queryParam("size", size) + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .when().get("/api/stories/kakao/{kakaoId}", kakaoId) + .then().statusCode(200); + } + + @EnumSource(value = BusinessErrorCode.class, names = {"PRESIGNED_URL_GENERATION_FAILED"}) + @ParameterizedTest + void 카카오_ID로_스토리_목록_조회_실패(BusinessErrorCode errorCode) { + String kakaoId = "nonexistent"; + int size = 5; + doThrow(new BusinessException(errorCode)).when(storyService).getPagedStoryDetails(kakaoId, size); + + var document = document("story/get-stories-by-kakao-id", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .queryParam("size", size) + .when().get("/api/stories/kakao/{kakaoId}", kakaoId) + .then().statusCode(errorCode.getStatus().value()); + } + } } diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index 04d2c1f8..c142dd30 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import eatda.client.map.StoreSearchResult; +import eatda.controller.story.StoriesDetailResponse; import eatda.controller.story.StoriesResponse.StoryPreview; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryResponse; @@ -27,7 +28,7 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; -public class StoryServiceTest extends BaseServiceTest { +class StoryServiceTest extends BaseServiceTest { @Autowired private StoryService storyService; @@ -187,4 +188,95 @@ class GetStory { .hasMessageContaining(BusinessErrorCode.STORY_NOT_FOUND.getMessage()); } } + + @Nested + class GetPagedStoryDetails { + + @Test + void 카카오ID로_스토리_목록을_조회할_수_있다() { + String kakaoId = "123456"; + Member member = memberGenerator.generate("12345"); + Story story1 = Story.builder() + .member(member) + .storeKakaoId(kakaoId) + .storeName("곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory(StoreCategory.KOREAN) + .description("미쳤다 진짜") + .imageKey(new ImageKey("image-key-1")) + .build(); + Story story2 = Story.builder() + .member(member) + .storeKakaoId(kakaoId) + .storeName("순대국밥집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory(StoreCategory.KOREAN) + .description("뜨끈한 국밥 최고") + .imageKey(new ImageKey("image-key-2")) + .build(); + storyRepository.save(story1); + storyRepository.save(story2); + when(externalImageStorage.getPreSignedUrl(new ImageKey("image-key-1"))) + .thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); + when(externalImageStorage.getPreSignedUrl(new ImageKey("image-key-2"))) + .thenReturn("https://s3.bucket.com/story/dummy/2.jpg"); + + var response = storyService.getPagedStoryDetails(kakaoId, 5); + + assertThat(response.stories()) + .hasSize(2) + .extracting(StoriesDetailResponse.StoryDetailResponse::storyId) + .containsExactlyInAnyOrder(story2.getId(), story1.getId()); + } + + @Test + void 카카오ID로_스토리_목록을_조회할_때_특정_스토리만_반환한다() { + String kakaoId = "123456"; + Member member = memberGenerator.generate("12345"); + Story story1 = Story.builder() + .member(member) + .storeKakaoId(kakaoId) + .storeName("곱창집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory(StoreCategory.KOREAN) + .description("미쳤다 진짜") + .imageKey(new ImageKey("image-key-1")) + .build(); + Story story2 = Story.builder() + .member(member) + .storeKakaoId("different-kakao-id") + .storeName("순대국밥집") + .storeRoadAddress("서울시 성동구 왕십리로 1길 12") + .storeLotNumberAddress("서울시 성동구 성수동1가 685-12") + .storeCategory(StoreCategory.KOREAN) + .description("뜨끈한 국밥 최고") + .imageKey(new ImageKey("image-key-2")) + .build(); + storyRepository.save(story1); + storyRepository.save(story2); + when(externalImageStorage.getPreSignedUrl(new ImageKey("image-key-1"))) + .thenReturn("https://s3.bucket.com/story/dummy/1.jpg"); + when(externalImageStorage.getPreSignedUrl(new ImageKey("image-key-2"))) + .thenReturn("https://s3.bucket.com/story/dummy/2.jpg"); + + var response = storyService.getPagedStoryDetails(kakaoId, 5); + + assertThat(response.stories()) + .hasSize(1) + .extracting(StoriesDetailResponse.StoryDetailResponse::storyId) + .containsExactlyInAnyOrder(story1.getId()); + } + } + + @Test + void 존재하지_않는_카카오ID로_조회하면_빈_목록을_반환한다() { + String nonExistentKakaoId = "non-existent"; + + var response = storyService.getPagedStoryDetails(nonExistentKakaoId, 5); + + assertThat(response.stories()).isEmpty(); + } }