diff --git a/src/main/java/eatda/controller/cheer/CheerController.java b/src/main/java/eatda/controller/cheer/CheerController.java index cb936d09..3d2325fd 100644 --- a/src/main/java/eatda/controller/cheer/CheerController.java +++ b/src/main/java/eatda/controller/cheer/CheerController.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Validated @RestController @RequiredArgsConstructor public class CheerController { diff --git a/src/main/java/eatda/controller/store/StoreController.java b/src/main/java/eatda/controller/store/StoreController.java index f80d76a2..24a35189 100644 --- a/src/main/java/eatda/controller/store/StoreController.java +++ b/src/main/java/eatda/controller/store/StoreController.java @@ -10,11 +10,13 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Validated @RestController @RequiredArgsConstructor public class StoreController { diff --git a/src/main/java/eatda/controller/story/StoriesInMemberResponse.java b/src/main/java/eatda/controller/story/StoriesInMemberResponse.java new file mode 100644 index 00000000..6ae9faed --- /dev/null +++ b/src/main/java/eatda/controller/story/StoriesInMemberResponse.java @@ -0,0 +1,6 @@ +package eatda.controller.story; + +import java.util.List; + +public record StoriesInMemberResponse(List stories) { +} diff --git a/src/main/java/eatda/controller/story/StoryController.java b/src/main/java/eatda/controller/story/StoryController.java index f3b2684a..a056565f 100644 --- a/src/main/java/eatda/controller/story/StoryController.java +++ b/src/main/java/eatda/controller/story/StoryController.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Validated @RestController @RequiredArgsConstructor public class StoryController { @@ -54,6 +56,17 @@ public ResponseEntity getStory(@PathVariable long storyId) { .body(storyService.getStory(storyId)); } + @GetMapping("/api/stories/member") + public ResponseEntity getStoriesByMemberId( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "5") @Min(1) @Max(50) int size, + LoginMember member + ) { + StoriesInMemberResponse response = storyService.getPagedStoryByMemberId(member.id(), page, size); + return ResponseEntity.status(HttpStatus.OK) + .body(response); + } + @GetMapping("/api/stories/kakao/{kakaoId}") public ResponseEntity getStoriesByKakaoId( @PathVariable String kakaoId, diff --git a/src/main/java/eatda/controller/story/StoryInMemberResponse.java b/src/main/java/eatda/controller/story/StoryInMemberResponse.java new file mode 100644 index 00000000..e9ee266a --- /dev/null +++ b/src/main/java/eatda/controller/story/StoryInMemberResponse.java @@ -0,0 +1,14 @@ +package eatda.controller.story; + +import eatda.domain.story.Story; + +public record StoryInMemberResponse( + Long id, + String imageUrl, + String storeName +) { + + public StoryInMemberResponse(Story story, String imageUrl) { + this(story.getId(), imageUrl, story.getStoreName()); + } +} diff --git a/src/main/java/eatda/repository/story/StoryRepository.java b/src/main/java/eatda/repository/story/StoryRepository.java index 8ddc602b..9f989384 100644 --- a/src/main/java/eatda/repository/story/StoryRepository.java +++ b/src/main/java/eatda/repository/story/StoryRepository.java @@ -3,11 +3,15 @@ import eatda.domain.story.Story; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface StoryRepository extends JpaRepository { Page findAllByOrderByCreatedAtDesc(Pageable pageable); + Page findAllByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); + + @EntityGraph(attributePaths = {"member"}) 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 8d3dd010..9fbdecc7 100644 --- a/src/main/java/eatda/service/story/StoryService.java +++ b/src/main/java/eatda/service/story/StoryService.java @@ -1,7 +1,10 @@ package eatda.service.story; import eatda.controller.story.StoriesDetailResponse; +import eatda.controller.story.StoriesInMemberResponse; import eatda.controller.story.StoriesResponse; +import eatda.controller.story.StoriesResponse.StoryPreview; +import eatda.controller.story.StoryInMemberResponse; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; import eatda.controller.story.StoryResponse; @@ -18,7 +21,6 @@ import eatda.storage.image.ImageStorage; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -58,16 +60,12 @@ public StoryRegisterResponse registerStory(StoryRegisterRequest request, @Transactional(readOnly = true) public StoriesResponse getPagedStoryPreviews(int size) { Pageable pageable = PageRequest.of(PAGE_START_NUMBER, size); - Page orderByPage = storyRepository.findAllByOrderByCreatedAtDesc(pageable); + List stories = storyRepository.findAllByOrderByCreatedAtDesc(pageable).getContent(); - return new StoriesResponse( - orderByPage.getContent().stream() - .map(story -> new StoriesResponse.StoryPreview( - story.getId(), - imageStorage.getPreSignedUrl(story.getImageKey()) - )) - .toList() - ); + List responses = stories.stream() + .map(story -> new StoryPreview(story.getId(), imageStorage.getPreSignedUrl(story.getImageKey()))) + .toList(); + return new StoriesResponse(responses); } @Transactional(readOnly = true) @@ -101,7 +99,18 @@ public StoriesDetailResponse getPagedStoryDetails(String kakaoId, int size) { List responses = stories.stream() .map(story -> new StoriesDetailResponse.StoryDetailResponse( story, imageStorage.getPreSignedUrl(story.getImageKey()))) - .toList(); // TODO: N+1 문제 해결 + .toList(); return new StoriesDetailResponse(responses); } + + @Transactional(readOnly = true) + public StoriesInMemberResponse getPagedStoryByMemberId(long memberId, int page, int size) { + List stories = storyRepository + .findAllByMemberIdOrderByCreatedAtDesc(memberId, PageRequest.of(page, size)) + .getContent(); + List responses = stories.stream() + .map(story -> new StoryInMemberResponse(story, imageStorage.getPreSignedUrl(story.getImageKey()))) + .toList(); + return new StoriesInMemberResponse(responses); + } } diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index bb58a198..42014a26 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -133,6 +133,10 @@ protected final String accessToken() { return jwtManager.issueAccessToken(member.getId()); } + protected final String accessToken(Member member) { + return jwtManager.issueAccessToken(member.getId()); + } + protected final String refreshToken() { Member member = memberGenerator.generateByEmail(Long.toString(DEFAULT_OAUTH_MEMBER_INFO.socialId()), "authRefreshToken@example.com"); diff --git a/src/test/java/eatda/controller/story/StoryControllerTest.java b/src/test/java/eatda/controller/story/StoryControllerTest.java index 259da87a..6701c35e 100644 --- a/src/test/java/eatda/controller/story/StoryControllerTest.java +++ b/src/test/java/eatda/controller/story/StoryControllerTest.java @@ -13,6 +13,7 @@ import java.time.LocalDateTime; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; public class StoryControllerTest extends BaseControllerTest { @@ -109,33 +110,63 @@ class GetStory { } @Nested - class GetPagedStoryDetailsByKakaoId { + class GetStoriesByMemberId { @Test - void 카카오ID로_스토리_목록을_조회할_수_있다() { - String kakaoId = "123456"; + void 회원의_스토리_목록을_조회할_수_있다() { + Member member = memberGenerator.generateRegisteredMember("nickname", "abc@kakao.com", "123", "01012345679"); LocalDateTime startAt = LocalDateTime.of(2025, 8, 5, 12, 0, 0); - Member member = memberGenerator.generateRegisteredMember("test", "test@kakao.com", "812", "01081231234"); - Story story1 = storyGenerator.generate(member, kakaoId, "진또곱창집", startAt); - Story story2 = storyGenerator.generate(member, kakaoId, "진또곱창집", startAt.plusHours(1)); - - StoriesDetailResponse response = given() - .pathParam("kakaoId", kakaoId) - .queryParam("size", 5) + Story story1 = storyGenerator.generate(member, "123456", "진또곱창집", startAt); + Story story2 = storyGenerator.generate(member, "654321", "또진곱창집", startAt.plusHours(1)); + int page = 0; + int size = 5; + + StoriesInMemberResponse response = given() + .header(HttpHeaders.AUTHORIZATION, accessToken(member)) + .queryParam("page", page) + .queryParam("size", size) .when() - .get("/api/stories/kakao/{kakaoId}") + .get("/api/stories/member") .then().statusCode(200) - .extract().as(StoriesDetailResponse.class); + .extract().as(StoriesInMemberResponse.class); assertAll( () -> assertThat(response.stories()).hasSize(2), - () -> assertThat(response.stories().getFirst().storyId()).isEqualTo(story2.getId()), - () -> assertThat(response.stories().getFirst().memberId()).isEqualTo(member.getId()), - () -> assertThat(response.stories().getFirst().memberNickname()).isEqualTo(member.getNickname()), - () -> assertThat(response.stories().get(1).storyId()).isEqualTo(story1.getId()), - () -> assertThat(response.stories().get(1).memberId()).isEqualTo(member.getId()), - () -> assertThat(response.stories().get(1).memberNickname()).isEqualTo(member.getNickname()) + () -> assertThat(response.stories().get(0).id()).isEqualTo(story2.getId()), + () -> assertThat(response.stories().get(1).id()).isEqualTo(story1.getId()) ); } + + @Nested + class GetStoriesByKakaoId { + + @Test + void 카카오ID로_스토리_목록을_조회할_수_있다() { + String kakaoId = "123456"; + LocalDateTime startAt = LocalDateTime.of(2025, 8, 5, 12, 0, 0); + Member member = memberGenerator.generateRegisteredMember("test", "test@kakao.com", "812", + "01081231234"); + Story story1 = storyGenerator.generate(member, kakaoId, "진또곱창집", startAt); + Story story2 = storyGenerator.generate(member, kakaoId, "진또곱창집", startAt.plusHours(1)); + + 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().get(0).storyId()).isEqualTo(story2.getId()), + () -> assertThat(response.stories().get(0).memberId()).isEqualTo(member.getId()), + () -> assertThat(response.stories().get(0).memberNickname()).isEqualTo(member.getNickname()), + () -> assertThat(response.stories().get(1).storyId()).isEqualTo(story1.getId()), + () -> assertThat(response.stories().get(1).memberId()).isEqualTo(member.getId()), + () -> assertThat(response.stories().get(1).memberNickname()).isEqualTo(member.getNickname()) + ); + } + } } } diff --git a/src/test/java/eatda/document/story/StoryDocumentTest.java b/src/test/java/eatda/document/story/StoryDocumentTest.java index 75edba5f..e83bf997 100644 --- a/src/test/java/eatda/document/story/StoryDocumentTest.java +++ b/src/test/java/eatda/document/story/StoryDocumentTest.java @@ -3,6 +3,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; @@ -15,7 +16,9 @@ import eatda.controller.story.StoriesDetailResponse; import eatda.controller.story.StoriesDetailResponse.StoryDetailResponse; +import eatda.controller.story.StoriesInMemberResponse; import eatda.controller.story.StoriesResponse; +import eatda.controller.story.StoryInMemberResponse; import eatda.controller.story.StoryRegisterRequest; import eatda.controller.story.StoryRegisterResponse; import eatda.controller.story.StoryResponse; @@ -233,6 +236,76 @@ class GetStory { } } + @Nested + class GetStoriesByMemberId { + + RestDocsRequest requestDocument = request() + .tag(Tag.STORY_API) + .summary("회원 ID로 스토리 목록 조회") + .requestHeader( + headerWithName(HttpHeaders.AUTHORIZATION).description("액세스 토큰") + ) + .queryParameter( + parameterWithName("page").description("페이지 번호 (기본값: 0) (최소값: 0)").optional(), + parameterWithName("size").description("스토리 개수 (기본값: 5) (최소값: 1, 최대값: 50)").optional() + ); + + RestDocsResponse responseDocument = response() + .responseBodyField( + fieldWithPath("stories").type(ARRAY).description("스토리 리스트"), + fieldWithPath("stories[].id").type(NUMBER).description("스토리 ID"), + fieldWithPath("stories[].imageUrl").type(STRING).description("스토리 이미지 URL"), + fieldWithPath("stories[].storeName").type(STRING).description("가게 이름") + ); + + @Test + void 회원_ID로_스토리_목록_조회_성공() { + int page = 0; + int size = 5; + StoriesInMemberResponse response = new StoriesInMemberResponse(List.of( + new StoryInMemberResponse(1L, "https://dummy-s3.com/story1.png", "백암순대"), + new StoryInMemberResponse(2L, "https://dummy-s3.com/story2.png", "맥도날드") + )); + doReturn(response).when(storyService).getPagedStoryByMemberId(anyLong(), eq(page), eq(size)); + + var document = document("story/get-stories-by-member-id", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .queryParam("page", page) + .queryParam("size", size) + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .when().get("/api/stories/member") + .then().statusCode(200); + } + + @EnumSource(value = BusinessErrorCode.class, names = { + "UNAUTHORIZED_MEMBER", + "EXPIRED_TOKEN", + "INVALID_MEMBER_ID"}) + @ParameterizedTest + void 회원_ID로_스토리_목록_조회_실패(BusinessErrorCode errorCode) { + int page = 0; + int size = 5; + doThrow(new BusinessException(errorCode)) + .when(storyService).getPagedStoryByMemberId(anyLong(), eq(page), eq(size)); + + var document = document("story/get-stories-by-member-id", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .queryParam("page", page) + .queryParam("size", size) + .header(HttpHeaders.AUTHORIZATION, accessToken()) + .when().get("/api/stories/member") + .then().statusCode(errorCode.getStatus().value()); + } + } + @Nested class GetStoriesByKakaoId { diff --git a/src/test/java/eatda/service/BaseServiceTest.java b/src/test/java/eatda/service/BaseServiceTest.java index 35230225..59042d05 100644 --- a/src/test/java/eatda/service/BaseServiceTest.java +++ b/src/test/java/eatda/service/BaseServiceTest.java @@ -10,6 +10,7 @@ import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; import eatda.fixture.StoreGenerator; +import eatda.fixture.StoryGenerator; import eatda.repository.cheer.CheerRepository; import eatda.repository.cheer.CheerTagRepository; import eatda.repository.member.MemberRepository; @@ -56,6 +57,9 @@ public abstract class BaseServiceTest { @Autowired protected CheerGenerator cheerGenerator; + @Autowired + protected StoryGenerator storyGenerator; + @Autowired protected MemberRepository memberRepository; diff --git a/src/test/java/eatda/service/story/StoryServiceTest.java b/src/test/java/eatda/service/story/StoryServiceTest.java index ae69507c..2fa71482 100644 --- a/src/test/java/eatda/service/story/StoryServiceTest.java +++ b/src/test/java/eatda/service/story/StoryServiceTest.java @@ -20,6 +20,7 @@ import eatda.exception.BusinessErrorCode; import eatda.exception.BusinessException; import eatda.service.BaseServiceTest; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -268,4 +269,52 @@ class GetPagedStoryDetails { .containsExactlyInAnyOrder(story1.getId()); } } + + @Nested + class GetPagedStoryByMemberId { + + @Test + void 회원_ID로_스토리_목록을_조회할_수_있다() { + Member member = memberGenerator.generate("12345"); + LocalDateTime startAt = LocalDateTime.of(2025, 7, 23, 10, 0); + Story story1 = storyGenerator.generate(member, "123456", "곱창집", startAt); + Story story2 = storyGenerator.generate(member, "123457", "순대국밥집", startAt.plusDays(1)); + + var response = storyService.getPagedStoryByMemberId(member.getId(), 0, 5); + + assertAll( + () -> assertThat(response.stories()).hasSize(2), + () -> assertThat(response.stories().get(0).id()).isEqualTo(story2.getId()), + () -> assertThat(response.stories().get(0).storeName()).isEqualTo(story2.getStoreName()), + () -> assertThat(response.stories().get(1).id()).isEqualTo(story1.getId()), + () -> assertThat(response.stories().get(1).storeName()).isEqualTo(story1.getStoreName()) + ); + } + + @Test + void 회원_ID로_스토리_목록을_페이지네이션할_수_있다() { + Member member = memberGenerator.generate("12345"); + LocalDateTime startAt = LocalDateTime.of(2025, 7, 23, 10, 0); + Story story1 = storyGenerator.generate(member, "123456", "곱창집", startAt); + Story story2 = storyGenerator.generate(member, "123457", "순대국밥집", startAt.plusDays(1)); + Story story3 = storyGenerator.generate(member, "123458", "김밥집", startAt.plusDays(2)); + + var response = storyService.getPagedStoryByMemberId(member.getId(), 1, 2); + + assertAll( + () -> assertThat(response.stories()).hasSize(1), + () -> assertThat(response.stories().get(0).id()).isEqualTo(story1.getId()), + () -> assertThat(response.stories().get(0).storeName()).isEqualTo(story1.getStoreName()) + ); + } + + @Test + void 회원_ID로_스토리_목록을_조회할_때_존재하지_않는_ID를_요청하면_빈_목록을_반환한다() { + long nonExistentMemberId = 999L; + + var response = storyService.getPagedStoryByMemberId(nonExistentMemberId, 0, 5); + + assertThat(response.stories()).isEmpty(); + } + } }