diff --git a/src/main/java/eatda/controller/store/StorePreviewResponse.java b/src/main/java/eatda/controller/store/StorePreviewResponse.java index 00a6e18f..cdb343ec 100644 --- a/src/main/java/eatda/controller/store/StorePreviewResponse.java +++ b/src/main/java/eatda/controller/store/StorePreviewResponse.java @@ -1,6 +1,7 @@ package eatda.controller.store; import eatda.domain.store.Store; +import java.util.List; public record StorePreviewResponse( long id, @@ -8,7 +9,8 @@ public record StorePreviewResponse( String name, String district, String neighborhood, - String category + String category, + List cheerDescriptions ) { public StorePreviewResponse(Store store, String imageUrl) { @@ -18,7 +20,8 @@ public StorePreviewResponse(Store store, String imageUrl) { store.getName(), store.getAddressDistrict(), store.getAddressNeighborhood(), - store.getCategory().getCategoryName() + store.getCategory().getCategoryName(), + store.getCheerDescriptions() ); } } diff --git a/src/main/java/eatda/domain/store/Store.java b/src/main/java/eatda/domain/store/Store.java index be8b13c4..ba4874b1 100644 --- a/src/main/java/eatda/domain/store/Store.java +++ b/src/main/java/eatda/domain/store/Store.java @@ -1,6 +1,7 @@ package eatda.domain.store; import eatda.domain.AuditingEntity; +import eatda.domain.cheer.Cheer; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -9,7 +10,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -54,6 +58,9 @@ public class Store extends AuditingEntity { @Embedded private Coordinates coordinates; + @OneToMany(mappedBy = "store") + private List cheers = new ArrayList<>(); + @Builder private Store(String kakaoId, StoreCategory category, @@ -87,4 +94,10 @@ public String getAddressNeighborhood() { } return addressParts[2]; } + + public List getCheerDescriptions() { + return cheers.stream() + .map(Cheer::getDescription) + .toList(); + } } diff --git a/src/main/java/eatda/repository/cheer/CheerImageRepository.java b/src/main/java/eatda/repository/cheer/CheerImageRepository.java index b324cdf6..cb829e32 100644 --- a/src/main/java/eatda/repository/cheer/CheerImageRepository.java +++ b/src/main/java/eatda/repository/cheer/CheerImageRepository.java @@ -1,13 +1,14 @@ package eatda.repository.cheer; import eatda.domain.cheer.CheerImage; +import eatda.domain.store.Store; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface CheerImageRepository extends JpaRepository { - List findAllByCheer_Store_IdOrderByOrderIndexAsc(Long storeId); + List findAllByCheer_StoreOrderByOrderIndexAsc(Store store); Optional findFirstByCheer_Store_IdOrderByCreatedAtDesc(Long storeId); } diff --git a/src/main/java/eatda/repository/store/StoreRepository.java b/src/main/java/eatda/repository/store/StoreRepository.java index c3d79b0a..5f5b3221 100644 --- a/src/main/java/eatda/repository/store/StoreRepository.java +++ b/src/main/java/eatda/repository/store/StoreRepository.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -20,8 +21,10 @@ default Store getById(Long id) { Optional findByKakaoId(String kakaoId); + @EntityGraph(attributePaths = {"cheers"}) List findAllByOrderByCreatedAtDesc(Pageable pageable); + @EntityGraph(attributePaths = {"cheers"}) List findAllByCategoryOrderByCreatedAtDesc(StoreCategory category, Pageable pageable); @Query(""" diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index b5a5123b..e59ee2b1 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -41,6 +41,7 @@ public StoreResponse getStore(long storeId) { } // TODO : N+1 문제 해결 + @Transactional(readOnly = true) public StoresResponse getStores(int page, int size, @Nullable String category) { return findStores(page, size, category) .stream() @@ -56,8 +57,10 @@ private List findStores(int page, int size, @Nullable String category) { StoreCategory.from(category), PageRequest.of(page, size)); } + @Transactional(readOnly = true) public ImagesResponse getStoreImages(long storeId) { - List urls = cheerImageRepository.findAllByCheer_Store_IdOrderByOrderIndexAsc(storeId) + Store store = storeRepository.getById(storeId); + List urls = cheerImageRepository.findAllByCheer_StoreOrderByOrderIndexAsc(store) .stream() .map(img -> "https://" + cdnBaseUrl + "/" + img.getImageKey()) .toList(); diff --git a/src/test/java/eatda/controller/BaseControllerTest.java b/src/test/java/eatda/controller/BaseControllerTest.java index af21bff9..9773ee24 100644 --- a/src/test/java/eatda/controller/BaseControllerTest.java +++ b/src/test/java/eatda/controller/BaseControllerTest.java @@ -1,9 +1,11 @@ package eatda.controller; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; import eatda.DatabaseCleaner; +import eatda.client.file.FileClient; import eatda.client.map.MapClient; import eatda.client.map.MapClientStoreSearchResult; import eatda.client.oauth.OauthClient; @@ -85,6 +87,9 @@ public class BaseControllerTest { @Autowired protected JwtManager jwtManager; + @MockitoBean + private FileClient fileClient; + @MockitoBean private OauthClient oauthClient; @@ -119,6 +124,8 @@ final void mockingClient() throws URISyntaxException { "서울 중구 북창동 19-4", null, 37.0d, 128.0d) ); doReturn(searchResults).when(mapClient).searchStores(anyString()); + + doReturn(MOCKED_IMAGE_URL).when(fileClient).generateUploadPresignedUrl(anyString(), any()); } protected final RequestSpecification given() { diff --git a/src/test/java/eatda/controller/store/StoreControllerTest.java b/src/test/java/eatda/controller/store/StoreControllerTest.java index fda3e223..87f33c4a 100644 --- a/src/test/java/eatda/controller/store/StoreControllerTest.java +++ b/src/test/java/eatda/controller/store/StoreControllerTest.java @@ -22,7 +22,7 @@ class GetStore { void 음식점_정보를_조회한다() { Member member = memberGenerator.generate("111"); Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); - cheerGenerator.generateCommon(member, store, false); + cheerGenerator.generateCommon(member, store); StoreResponse response = given() .pathParam("storeId", store.getId()) @@ -53,9 +53,9 @@ class GetStores { startAt.plusHours(1)); Store store3 = storeGenerator.generate("114", "서울 강남구 역삼동 678-90", StoreCategory.KOREAN, startAt.plusHours(2)); - cheerGenerator.generateCommon(member, store1, false); - cheerGenerator.generateCommon(member, store2, false); - cheerGenerator.generateCommon(member, store3, false); + cheerGenerator.generateCommon(member, store1); + cheerGenerator.generateCommon(member, store2); + cheerGenerator.generateCommon(member, store3); int page = 0; int size = 2; @@ -85,9 +85,9 @@ class GetStores { startAt.plusHours(1)); Store store3 = storeGenerator.generate("114", "서울 강남구 역삼동 678-90", StoreCategory.CAFE, startAt.plusHours(2)); - cheerGenerator.generateCommon(member, store1, false); - cheerGenerator.generateCommon(member, store2, false); - cheerGenerator.generateCommon(member, store3, false); + cheerGenerator.generateCommon(member, store1); + cheerGenerator.generateCommon(member, store2); + cheerGenerator.generateCommon(member, store3); int page = 0; int size = 2; @@ -118,7 +118,7 @@ class GetStoreImages { void 음식점_이미지들을_조회한다() { Member member = memberGenerator.generate("111"); Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); - Cheer cheer = cheerGenerator.generateCommon(member, store, false); + Cheer cheer = cheerGenerator.generateCommon(member, store); cheerImageGenerator.generate(cheer, "image1.png", 1L); cheerImageGenerator.generate(cheer, "image2.png", 2L); diff --git a/src/test/java/eatda/document/store/StoreDocumentTest.java b/src/test/java/eatda/document/store/StoreDocumentTest.java index 5ae172fc..868baefd 100644 --- a/src/test/java/eatda/document/store/StoreDocumentTest.java +++ b/src/test/java/eatda/document/store/StoreDocumentTest.java @@ -116,7 +116,8 @@ class GetStores { fieldWithPath("stores[].name").type(STRING).description("음식점 이름"), fieldWithPath("stores[].district").type(STRING).description("음식점 주소 (구)"), fieldWithPath("stores[].neighborhood").type(STRING).description("음식점 주소 (동)"), - fieldWithPath("stores[].category").type(STRING).description("음식점 카테고리") + fieldWithPath("stores[].category").type(STRING).description("음식점 카테고리"), + fieldWithPath("stores[].cheerDescriptions").type(ARRAY).description("음식점에 달린 응원 메시지") ); @Test @@ -125,8 +126,10 @@ class GetStores { int size = 2; StoreCategory category = StoreCategory.CAFE; StoresResponse response = new StoresResponse(List.of( - new StorePreviewResponse(2L, "https://example.image", "농민백암순대", "강남구", "대치동", "한식"), - new StorePreviewResponse(1L, "https://example.image", "석관동떡볶이", "성북구", "석관동", "한식") + new StorePreviewResponse(2L, "https://example.image", "농민백암순대", "강남구", "대치동", "한식", + List.of("응원해요!", "순대가 맛돌이!")), + new StorePreviewResponse(1L, "https://example.image", "석관동떡볶이", "성북구", "석관동", "한식", + List.of("응원해요!", "떡볶이가 맛있게 매워요~", "매운 떡볶이 최고!")) )); doReturn(response).when(storeService).getStores(page, size, category.getCategoryName()); diff --git a/src/test/java/eatda/fixture/CheerGenerator.java b/src/test/java/eatda/fixture/CheerGenerator.java index 13cb1014..69770ab7 100644 --- a/src/test/java/eatda/fixture/CheerGenerator.java +++ b/src/test/java/eatda/fixture/CheerGenerator.java @@ -29,10 +29,6 @@ public Cheer generateCommon(Member member, Store store) { return generateCommon(member, store, false, DEFAULT_DESCRIPTION); } - public Cheer generateCommon(Member member, Store store, boolean isAdmin) { - return generateCommon(member, store, isAdmin, DEFAULT_DESCRIPTION); - } - public Cheer generateCommon(Member member, Store store, LocalDateTime createdAt) { Cheer cheer = generateCommon(member, store, false, DEFAULT_DESCRIPTION); DomainUtils.setCreatedAt(cheer, createdAt); diff --git a/src/test/java/eatda/service/store/StoreServiceTest.java b/src/test/java/eatda/service/store/StoreServiceTest.java index fe1df2c7..c48f868b 100644 --- a/src/test/java/eatda/service/store/StoreServiceTest.java +++ b/src/test/java/eatda/service/store/StoreServiceTest.java @@ -2,14 +2,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import eatda.controller.store.ImagesResponse; import eatda.controller.store.StoreResponse; import eatda.controller.store.StoresInMemberResponse; import eatda.controller.store.StoresResponse; +import eatda.domain.cheer.Cheer; import eatda.domain.member.Member; import eatda.domain.store.District; import eatda.domain.store.Store; import eatda.domain.store.StoreCategory; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; import eatda.service.BaseServiceTest; import java.time.LocalDateTime; import org.junit.jupiter.api.Nested; @@ -28,7 +33,7 @@ class GetStore { void 가게_정보를_조회한다() { Member member = memberGenerator.generate("111"); Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); - cheerGenerator.generateCommon(member, store, false); + cheerGenerator.generateCommon(member, store); StoreResponse response = storeService.getStore(store.getId()); @@ -40,24 +45,35 @@ class GetStore { () -> assertThat(response.neighborhood()).isEqualTo(store.getAddressNeighborhood()) ); } + + @Test + void 해당_음식점이_없을_경우_예외를_던진다() { + long nonExistentStoreId = 999L; + + BusinessException exception = assertThrows(BusinessException.class, + () -> storeService.getStore(nonExistentStoreId)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.STORE_NOT_FOUND); + } } @Nested class GetStores { @Test - void 음식점_목록을_최신순으로_조회한다() { - Member member = memberGenerator.generate("111"); + void 모든_카테고리의_음식점_목록을_최신순으로_조회한다() { + Member member1 = memberGenerator.generate("111", "ac@kakao.com", "nickname1"); + Member member2 = memberGenerator.generate("113", "ad@kakao.com", "nickname2"); LocalDateTime startAt = LocalDateTime.of(2025, 7, 26, 1, 0, 0); - Store store1 = storeGenerator.generate("112", "서울 강남구 대치동 896-33", StoreCategory.KOREAN, startAt); - Store store2 = storeGenerator.generate("113", "서울 성북구 석관동 123-45", StoreCategory.OTHER, + Store store1 = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33", StoreCategory.KOREAN, startAt); + Store store2 = storeGenerator.generate("석관동떡볶이", "서울 성북구 석관동 123-45", StoreCategory.OTHER, startAt.plusHours(1)); - Store store3 = storeGenerator.generate("114", "서울 강남구 역삼동 678-90", StoreCategory.KOREAN, + Store store3 = storeGenerator.generate("강남순대국", "서울 강남구 역삼동 678-90", StoreCategory.KOREAN, startAt.plusHours(2)); - cheerGenerator.generateCommon(member, store1, false); - cheerGenerator.generateCommon(member, store2, false); - cheerGenerator.generateCommon(member, store3, false); - + cheerGenerator.generateCommon(member1, store1); + cheerGenerator.generateCommon(member1, store2); + cheerGenerator.generateCommon(member2, store2); + cheerGenerator.generateCommon(member1, store3); int page = 0; int size = 2; @@ -66,7 +82,11 @@ class GetStores { assertAll( () -> assertThat(response.stores()).hasSize(size), () -> assertThat(response.stores().get(0).id()).isEqualTo(store3.getId()), - () -> assertThat(response.stores().get(1).id()).isEqualTo(store2.getId()) + () -> assertThat(response.stores().get(0).name()).isEqualTo(store3.getName()), + () -> assertThat(response.stores().get(0).cheerDescriptions()).hasSize(1), + () -> assertThat(response.stores().get(1).id()).isEqualTo(store2.getId()), + () -> assertThat(response.stores().get(1).name()).isEqualTo(store2.getName()), + () -> assertThat(response.stores().get(1).cheerDescriptions()).hasSize(2) ); } @@ -79,9 +99,9 @@ class GetStores { startAt.plusHours(1)); Store store3 = storeGenerator.generate("114", "서울 강남구 역삼동 678-90", StoreCategory.CAFE, startAt.plusHours(2)); - cheerGenerator.generateCommon(member, store1, false); - cheerGenerator.generateCommon(member, store2, false); - cheerGenerator.generateCommon(member, store3, false); + cheerGenerator.generateCommon(member, store1); + cheerGenerator.generateCommon(member, store2); + cheerGenerator.generateCommon(member, store3); int page = 0; int size = 2; @@ -95,6 +115,90 @@ class GetStores { () -> assertThat(response.stores().get(1).id()).isEqualTo(store1.getId()) ); } + + @Test + void 음식점_목록을_페이지네이션하여_조회한다() { + Member member = memberGenerator.generate("111"); + LocalDateTime startAt = LocalDateTime.of(2025, 7, 26, 1, 0, 0); + Store store1 = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33", StoreCategory.KOREAN, startAt); + Store store2 = storeGenerator.generate("석관동떡볶이", "서울 성북구 석관동 123-45", StoreCategory.OTHER, + startAt.plusHours(1)); + Store store3 = storeGenerator.generate("강남순대국", "서울 강남구 역삼동 678-90", StoreCategory.KOREAN, + startAt.plusHours(2)); + cheerGenerator.generateCommon(member, store1); + cheerGenerator.generateCommon(member, store2); + cheerGenerator.generateCommon(member, store3); + int page = 1; + int size = 2; + + var response = storeService.getStores(page, size, null); + + assertAll( + () -> assertThat(response.stores()).hasSize(1), + () -> assertThat(response.stores().get(0).id()).isEqualTo(store1.getId()) + ); + } + + @Test + void 특정_카테고리의_음식점_목록을_페이지네이션하여_조회한다() { + Member member = memberGenerator.generate("111"); + LocalDateTime startAt = LocalDateTime.of(2025, 7, 26, 1, 0, 0); + Store store1 = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33", StoreCategory.KOREAN, startAt); + Store store2 = storeGenerator.generate("석관동떡볶이", "서울 성북구 석관동 123-45", StoreCategory.OTHER, + startAt.plusHours(1)); + Store store3 = storeGenerator.generate("강남순대국", "서울 강남구 역삼동 678-90", StoreCategory.KOREAN, + startAt.plusHours(2)); + cheerGenerator.generateCommon(member, store1); + cheerGenerator.generateCommon(member, store2); + cheerGenerator.generateCommon(member, store3); + int page = 1; + int size = 1; + StoreCategory category = StoreCategory.KOREAN; + + var response = storeService.getStores(page, size, category.getCategoryName()); + + assertAll( + () -> assertThat(response.stores()).hasSize(1), + () -> assertThat(response.stores().get(0).id()).isEqualTo(store1.getId()) + ); + } + } + + @Nested + class GetStoreImages { + + @Test + void 음식점_이미지들을_조회한다() { + Member member = memberGenerator.generate("111"); + Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); + Cheer cheer1 = cheerGenerator.generateCommon(member, store); + Cheer cheer2 = cheerGenerator.generateCommon(member, store); + cheerImageGenerator.generate(cheer1); + cheerImageGenerator.generate(cheer2); + + ImagesResponse response = storeService.getStoreImages(store.getId()); + + assertThat(response.imageUrls()).hasSize(2); + } + + @Test + void 음식점_이미지가_없다면_빈_리스트를_반환한다() { + Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); + + ImagesResponse response = storeService.getStoreImages(store.getId()); + + assertThat(response.imageUrls()).isEmpty(); + } + + @Test + void 음식점이_존재하지_않으면_예외를_발생시킨다() { + long nonExistentStoreId = 999L; + + BusinessException exception = assertThrows(BusinessException.class, + () -> storeService.getStoreImages(nonExistentStoreId)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.STORE_NOT_FOUND); + } } @Nested @@ -135,5 +239,4 @@ class GetStoresByCheeredMember { assertThat(response.stores()).isEmpty(); } } - }