diff --git a/src/main/java/eatda/controller/store/StoreController.java b/src/main/java/eatda/controller/store/StoreController.java index 1e8a684b..09d8e0a9 100644 --- a/src/main/java/eatda/controller/store/StoreController.java +++ b/src/main/java/eatda/controller/store/StoreController.java @@ -2,6 +2,8 @@ import eatda.controller.web.auth.LoginMember; import eatda.service.store.StoreService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +16,11 @@ public class StoreController { private final StoreService storeService; + @GetMapping("/api/shops") + public ResponseEntity getStores(@RequestParam @Min(1) @Max(50) int size) { + return ResponseEntity.ok(storeService.getStores(size)); + } + @GetMapping("/api/shop/search") public ResponseEntity searchStore(@RequestParam String query, LoginMember member) { StoreSearchResponses response = storeService.searchStores(query); diff --git a/src/main/java/eatda/controller/store/StorePreviewResponse.java b/src/main/java/eatda/controller/store/StorePreviewResponse.java new file mode 100644 index 00000000..00a6e18f --- /dev/null +++ b/src/main/java/eatda/controller/store/StorePreviewResponse.java @@ -0,0 +1,24 @@ +package eatda.controller.store; + +import eatda.domain.store.Store; + +public record StorePreviewResponse( + long id, + String imageUrl, + String name, + String district, + String neighborhood, + String category +) { + + public StorePreviewResponse(Store store, String imageUrl) { + this( + store.getId(), + imageUrl, + store.getName(), + store.getAddressDistrict(), + store.getAddressNeighborhood(), + store.getCategory().getCategoryName() + ); + } +} diff --git a/src/main/java/eatda/controller/store/StoresResponse.java b/src/main/java/eatda/controller/store/StoresResponse.java new file mode 100644 index 00000000..a5652aab --- /dev/null +++ b/src/main/java/eatda/controller/store/StoresResponse.java @@ -0,0 +1,6 @@ +package eatda.controller.store; + +import java.util.List; + +public record StoresResponse(List stores) { +} diff --git a/src/main/java/eatda/repository/store/CheerRepository.java b/src/main/java/eatda/repository/store/CheerRepository.java index 2ead698c..0ea9c490 100644 --- a/src/main/java/eatda/repository/store/CheerRepository.java +++ b/src/main/java/eatda/repository/store/CheerRepository.java @@ -1,8 +1,11 @@ package eatda.repository.store; import eatda.domain.store.Cheer; +import eatda.domain.store.Store; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; public interface CheerRepository extends Repository { @@ -10,4 +13,11 @@ public interface CheerRepository extends Repository { Cheer save(Cheer cheer); List findAllByOrderByCreatedAtDesc(Pageable pageable); + + @Query(""" + SELECT c.imageKey FROM Cheer c + WHERE c.store = :store AND c.imageKey IS NOT NULL + ORDER BY c.createdAt DESC + LIMIT 1""") + Optional findRecentImageKey(Store store); } diff --git a/src/main/java/eatda/repository/store/StoreRepository.java b/src/main/java/eatda/repository/store/StoreRepository.java index dc7346ed..8fc482fb 100644 --- a/src/main/java/eatda/repository/store/StoreRepository.java +++ b/src/main/java/eatda/repository/store/StoreRepository.java @@ -1,9 +1,13 @@ package eatda.repository.store; import eatda.domain.store.Store; +import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.repository.Repository; public interface StoreRepository extends Repository { Store save(Store store); + + List findAllByOrderByCreatedAtDesc(Pageable pageable); } diff --git a/src/main/java/eatda/service/store/CheerService.java b/src/main/java/eatda/service/store/CheerService.java index 5b268ce6..e8a98a39 100644 --- a/src/main/java/eatda/service/store/CheerService.java +++ b/src/main/java/eatda/service/store/CheerService.java @@ -7,7 +7,7 @@ import eatda.service.common.ImageService; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +20,7 @@ public class CheerService { @Transactional(readOnly = true) public CheersResponse getCheers(int size) { - PageRequest pageRequest = PageRequest.of(0, size); - List cheers = cheerRepository.findAllByOrderByCreatedAtDesc(pageRequest); + List cheers = cheerRepository.findAllByOrderByCreatedAtDesc(Pageable.ofSize(size)); return toCheersResponse(cheers); } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index a9096e9c..ded2b722 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -1,10 +1,21 @@ package eatda.service.store; +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toList; + import eatda.client.map.MapClient; import eatda.client.map.StoreSearchResult; +import eatda.controller.store.StorePreviewResponse; import eatda.controller.store.StoreSearchResponses; +import eatda.controller.store.StoresResponse; +import eatda.domain.store.Store; +import eatda.repository.store.CheerRepository; +import eatda.repository.store.StoreRepository; +import eatda.service.common.ImageService; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -13,6 +24,22 @@ public class StoreService { private final MapClient mapClient; private final StoreSearchFilter storeSearchFilter; + private final StoreRepository storeRepository; + private final CheerRepository cheerRepository; + private final ImageService imageService; + + // TODO : N+1 문제 해결 + public StoresResponse getStores(int size) { + return storeRepository.findAllByOrderByCreatedAtDesc(Pageable.ofSize(size)) + .stream() + .map(store -> new StorePreviewResponse(store, getStoreImageUrl(store).orElse(null))) + .collect(collectingAndThen(toList(), StoresResponse::new)); + } + + private Optional getStoreImageUrl(Store store) { + return cheerRepository.findRecentImageKey(store) + .map(imageService::getPresignedUrl); + } public StoreSearchResponses searchStores(String query) { List searchResults = mapClient.searchShops(query); diff --git a/src/test/java/eatda/controller/store/StoreControllerTest.java b/src/test/java/eatda/controller/store/StoreControllerTest.java index d71e1512..eac387c2 100644 --- a/src/test/java/eatda/controller/store/StoreControllerTest.java +++ b/src/test/java/eatda/controller/store/StoreControllerTest.java @@ -1,14 +1,48 @@ package eatda.controller.store; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import eatda.controller.BaseControllerTest; +import eatda.domain.member.Member; +import eatda.domain.store.Store; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; class StoreControllerTest extends BaseControllerTest { + @Nested + class GetStores { + + @Test + void 음식점_목록을_최신순으로_조회한다() { + Member member = memberGenerator.generate("111"); + Store store1 = storeGenerator.generate("111", "서울 강남구 대치동 896-33"); + Store store2 = storeGenerator.generate("222", "서울 강남구 대치동 896-34"); + Store store3 = storeGenerator.generate("333", "서울 강남구 대치동 896-35"); + cheerGenerator.generateCommon(member, store1, "image-key-1"); + cheerGenerator.generateCommon(member, store2, "image-key-2"); + cheerGenerator.generateCommon(member, store3, "image-key-3"); + + int size = 2; + + StoresResponse response = given() + .queryParam("size", size) + .when() + .get("/api/shops") + .then() + .statusCode(200) + .extract().as(StoresResponse.class); + + assertAll( + () -> assertThat(response.stores()).hasSize(size), + () -> assertThat(response.stores().get(0).id()).isEqualTo(store3.getId()), + () -> assertThat(response.stores().get(1).id()).isEqualTo(store2.getId()) + ); + } + } + @Nested class SearchStores { diff --git a/src/test/java/eatda/document/store/StoreDocumentTest.java b/src/test/java/eatda/document/store/StoreDocumentTest.java index e6a3b5ea..31210808 100644 --- a/src/test/java/eatda/document/store/StoreDocumentTest.java +++ b/src/test/java/eatda/document/store/StoreDocumentTest.java @@ -1,17 +1,21 @@ package eatda.document.store; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; 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 eatda.controller.store.StorePreviewResponse; import eatda.controller.store.StoreSearchResponse; import eatda.controller.store.StoreSearchResponses; +import eatda.controller.store.StoresResponse; import eatda.document.BaseDocumentTest; import eatda.document.RestDocsRequest; import eatda.document.RestDocsResponse; @@ -28,6 +32,67 @@ public class StoreDocumentTest extends BaseDocumentTest { + @Nested + class GetStores { + + RestDocsRequest requestDocument = request() + .tag(Tag.STORE_API) + .summary("음식점 목록 조회") + .queryParameter( + parameterWithName("size").description("조회할 음식점 개수 (최소 1, 최대 50)") + ); + + RestDocsResponse responseDocument = response() + .responseBodyField( + fieldWithPath("stores").type(ARRAY).description("음식점 목록"), + fieldWithPath("stores[].id").type(NUMBER).description("음식점 ID"), + fieldWithPath("stores[].imageUrl").type(STRING).description("음식점 대표 이미지 URL"), + fieldWithPath("stores[].name").type(STRING).description("음식점 이름"), + fieldWithPath("stores[].district").type(STRING).description("음식점 주소 (구)"), + fieldWithPath("stores[].neighborhood").type(STRING).description("음식점 주소 (동)"), + fieldWithPath("stores[].category").type(STRING).description("음식점 카테고리") + ); + + @Test + void 음식점_목록_최신순으로_조회() { + StoresResponse response = new StoresResponse(List.of( + new StorePreviewResponse(2L, "https://example.image", "농민백암순대", "강남구", "대치동", "한식"), + new StorePreviewResponse(1L, "https://example.image", "석관동떡볶이", "성북구", "석관동", "한식") + )); + doReturn(response).when(storeService).getStores(anyInt()); + + int size = 2; + var document = document("store/get", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("size", size) + .when().get("/api/shops") + .then().statusCode(200); + } + + @EnumSource(value = BusinessErrorCode.class, names = {"PRESIGNED_URL_GENERATION_FAILED"}) + @ParameterizedTest + void 음식점_목록_조회_실패(BusinessErrorCode errorCode) { + doThrow(new BusinessException(errorCode)).when(storeService).getStores(anyInt()); + + int size = 2; + var document = document("store/get", errorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .queryParam("size", size) + .when().get("/api/shops") + .then().statusCode(errorCode.getStatus().value()); + } + } + @Nested class SearchStores { diff --git a/src/test/java/eatda/fixture/CheerGenerator.java b/src/test/java/eatda/fixture/CheerGenerator.java index 5cf1e6f7..1a3af6a7 100644 --- a/src/test/java/eatda/fixture/CheerGenerator.java +++ b/src/test/java/eatda/fixture/CheerGenerator.java @@ -19,12 +19,16 @@ public CheerGenerator(CheerRepository cheerRepository) { } public Cheer generateAdmin(Member member, Store store) { - Cheer cheer = new Cheer(member, store, DEFAULT_IMAGE_KEY, DEFAULT_DESCRIPTION, true); + Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, DEFAULT_IMAGE_KEY, true); return cheerRepository.save(cheer); } public Cheer generateCommon(Member member, Store store) { - Cheer cheer = new Cheer(member, store, DEFAULT_IMAGE_KEY, DEFAULT_DESCRIPTION, false); + return generateCommon(member, store, DEFAULT_IMAGE_KEY); + } + + public Cheer generateCommon(Member member, Store store, String imageKey) { + Cheer cheer = new Cheer(member, store, DEFAULT_DESCRIPTION, imageKey, false); return cheerRepository.save(cheer); } } diff --git a/src/test/java/eatda/repository/BaseRepositoryTest.java b/src/test/java/eatda/repository/BaseRepositoryTest.java index 25fdea45..2fd33200 100644 --- a/src/test/java/eatda/repository/BaseRepositoryTest.java +++ b/src/test/java/eatda/repository/BaseRepositoryTest.java @@ -1,16 +1,38 @@ package eatda.repository; +import eatda.fixture.CheerGenerator; import eatda.fixture.MemberGenerator; +import eatda.fixture.StoreGenerator; import eatda.repository.member.MemberRepository; +import eatda.repository.store.CheerRepository; +import eatda.repository.store.StoreRepository; +import eatda.repository.story.StoryRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +@Import({MemberGenerator.class, StoreGenerator.class, CheerGenerator.class}) @DataJpaTest public abstract class BaseRepositoryTest { @Autowired protected MemberGenerator memberGenerator; + @Autowired + protected StoreGenerator storeGenerator; + + @Autowired + protected CheerGenerator cheerGenerator; + @Autowired protected MemberRepository memberRepository; + + @Autowired + protected StoreRepository storeRepository; + + @Autowired + protected CheerRepository cheerRepository; + + @Autowired + protected StoryRepository storyRepository; } diff --git a/src/test/java/eatda/repository/store/CheerRepositoryTest.java b/src/test/java/eatda/repository/store/CheerRepositoryTest.java new file mode 100644 index 00000000..88b8ef31 --- /dev/null +++ b/src/test/java/eatda/repository/store/CheerRepositoryTest.java @@ -0,0 +1,44 @@ +package eatda.repository.store; + +import static org.assertj.core.api.Assertions.assertThat; + +import eatda.domain.member.Member; +import eatda.domain.store.Store; +import eatda.repository.BaseRepositoryTest; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CheerRepositoryTest extends BaseRepositoryTest { + + @Nested + class FindRecentImageKey { + + @Test + void 응원들_중_최근_null이_아닌_이미지_키를_조회한다() throws InterruptedException { + Member member = memberGenerator.generate("111"); + Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); + cheerGenerator.generateCommon(member, store, "image-key-1"); + Thread.sleep(5); + cheerGenerator.generateCommon(member, store, "image-key-2"); + cheerGenerator.generateCommon(member, store, null); + + Optional imageKey = cheerRepository.findRecentImageKey(store); + + assertThat(imageKey).contains("image-key-2"); + } + + @Test + void 응원들의_이미지가_모두_비어있다면_해당_값이_없다() { + Member member = memberGenerator.generate("111"); + Store store = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); + cheerGenerator.generateCommon(member, store, null); + cheerGenerator.generateCommon(member, store, null); + cheerGenerator.generateCommon(member, store, null); + + Optional imageKey = cheerRepository.findRecentImageKey(store); + + assertThat(imageKey).isEmpty(); + } + } +} diff --git a/src/test/java/eatda/service/store/StoreServiceTest.java b/src/test/java/eatda/service/store/StoreServiceTest.java index 7e2873a9..b36b95bb 100644 --- a/src/test/java/eatda/service/store/StoreServiceTest.java +++ b/src/test/java/eatda/service/store/StoreServiceTest.java @@ -6,24 +6,42 @@ import static org.mockito.Mockito.doReturn; import eatda.client.map.StoreSearchResult; +import eatda.domain.member.Member; +import eatda.domain.store.Store; import eatda.service.BaseServiceTest; import java.util.List; -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; class StoreServiceTest extends BaseServiceTest { - @BeforeEach - void mockingClient() { - List searchResults = List.of( - new StoreSearchResult("123", "FD6", "음식점 > 한식 > 국밥", "010-1234-1234", "농민백암순대 본점", "https://yapp.co.kr", - "서울 강남구 대치동 896-33", "서울 강남구 선릉로86길 40-4", 37.0d, 128.0d), - new StoreSearchResult("456", "FD6", "음식점 > 한식 > 국밥", "010-1234-1234", "농민백암순대 시청점", "http://yapp.kr", - "서울 중구 북창동 19-4", null, 37.0d, 128.0d) - ); + @Autowired + private StoreService storeService; - doReturn(searchResults).when(mapClient).searchShops(anyString()); + @Nested + class GetStores { + + @Test + void 음식점_목록을_최신순으로_조회한다() { + Member member = memberGenerator.generate("111"); + Store store1 = storeGenerator.generate("농민백암순대", "서울 강남구 대치동 896-33"); + cheerGenerator.generateCommon(member, store1, "image-key-1"); + Store store2 = storeGenerator.generate("석관동떡볶이", "서울 성북구 석관동 123-45"); + cheerGenerator.generateCommon(member, store2, "image-key-2"); + Store store3 = storeGenerator.generate("강남순대국", "서울 강남구 역삼동 678-90"); + cheerGenerator.generateCommon(member, store3, "image-key-3"); + + int size = 2; + + var response = storeService.getStores(size); + + assertAll( + () -> assertThat(response.stores()).hasSize(size), + () -> assertThat(response.stores().get(0).id()).isEqualTo(store3.getId()), + () -> assertThat(response.stores().get(1).id()).isEqualTo(store2.getId()) + ); + } } @Nested @@ -31,7 +49,7 @@ class SearchStores { @Test void 음식점_검색_결과를_반환한다() { - StoreService storeService = new StoreService(mapClient, new StoreSearchFilter()); + mockingMapClient(); String query = "농민백암순대"; var response = storeService.searchStores(query); @@ -45,4 +63,15 @@ class SearchStores { ); } } + + void mockingMapClient() { + List searchResults = List.of( + new StoreSearchResult("123", "FD6", "음식점 > 한식 > 국밥", "010-1234-1234", "농민백암순대 본점", "https://yapp.co.kr", + "서울 강남구 대치동 896-33", "서울 강남구 선릉로86길 40-4", 37.0d, 128.0d), + new StoreSearchResult("456", "FD6", "음식점 > 한식 > 국밥", "010-1234-1234", "농민백암순대 시청점", "http://yapp.kr", + "서울 중구 북창동 19-4", null, 37.0d, 128.0d) + ); + + doReturn(searchResults).when(mapClient).searchShops(anyString()); + } }