Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/java/eatda/controller/store/StoreController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +16,11 @@ public class StoreController {

private final StoreService storeService;

@GetMapping("/api/shops")
public ResponseEntity<StoresResponse> getStores(@RequestParam @Min(1) @Max(50) int size) {
return ResponseEntity.ok(storeService.getStores(size));
}

@GetMapping("/api/shop/search")
public ResponseEntity<StoreSearchResponses> searchStore(@RequestParam String query, LoginMember member) {
StoreSearchResponses response = storeService.searchStores(query);
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/eatda/controller/store/StorePreviewResponse.java
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
6 changes: 6 additions & 0 deletions src/main/java/eatda/controller/store/StoresResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package eatda.controller.store;

import java.util.List;

public record StoresResponse(List<StorePreviewResponse> stores) {
}
10 changes: 10 additions & 0 deletions src/main/java/eatda/repository/store/CheerRepository.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
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<Cheer, Long> {

Cheer save(Cheer cheer);

List<Cheer> 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""")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문]
옷 JPQL에서 LIMIT 이 부분 문제없나요??
QuerySyntaxException 나는것으로 알고있는데 최근에 지원하게 바뀐건지 궁금합니다!

아니면 이런식의 방향은 어떠신가요...?

@Query("""
SELECT c.imageKey FROM Cheer c
    WHERE c.store = :store AND c.imageKey IS NOT NULL
    ORDER BY c.createdAt DESC
""")
List<String> findRecentImageKeys(Store store, Pageable pageable);

default Optional<String> findRecentImageKey(Store store) {
    return findRecentImageKeys(store, PageRequest.of(0, 1)).stream().findFirst();
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image image

LIMIT을 쓰는 방법도 좋고, (하위 호환을 고려한다면) 승로님이 말해주신 방법도 좋을 것 같아요!
어떻게 하는 것을 선호하실까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와우 역시 최근에 지원하게 바뀌었나보네요
Limit이 된다면... 그대로 가시죠 ㅋㅋㅋㅋ
저희 서비스는 신규 서비스니까... 하위 호환은... 잠시 잊는것으로..

Optional<String> findRecentImageKey(Store store);
}
4 changes: 4 additions & 0 deletions src/main/java/eatda/repository/store/StoreRepository.java
Original file line number Diff line number Diff line change
@@ -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, Long> {

Store save(Store store);

List<Store> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
5 changes: 2 additions & 3 deletions src/main/java/eatda/service/store/CheerService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,8 +20,7 @@ public class CheerService {

@Transactional(readOnly = true)
public CheersResponse getCheers(int size) {
PageRequest pageRequest = PageRequest.of(0, size);
List<Cheer> cheers = cheerRepository.findAllByOrderByCreatedAtDesc(pageRequest);
List<Cheer> cheers = cheerRepository.findAllByOrderByCreatedAtDesc(Pageable.ofSize(size));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size를 클라이언트에게 받는군요...!
흠.... 흠..... 이거는 모든 컨트롤러에서 맞추는게 좋을것 같은데
Story도 일단 size를 받도록 다음 PR에 저도 반영할께요

return toCheersResponse(cheers);
}

Expand Down
27 changes: 27 additions & 0 deletions src/main/java/eatda/service/store/StoreService.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 문제 해결
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

단골 문제가 나온것 같군요 🥲
TODO 주석 좋습니다 👍🏻

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<String> getStoreImageUrl(Store store) {
return cheerRepository.findRecentImageKey(store)
.map(imageService::getPresignedUrl);
}

public StoreSearchResponses searchStores(String query) {
List<StoreSearchResult> searchResults = mapClient.searchShops(query);
Expand Down
34 changes: 34 additions & 0 deletions src/test/java/eatda/controller/store/StoreControllerTest.java
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down
65 changes: 65 additions & 0 deletions src/test/java/eatda/document/store/StoreDocumentTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {

Expand Down
8 changes: 6 additions & 2 deletions src/test/java/eatda/fixture/CheerGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
22 changes: 22 additions & 0 deletions src/test/java/eatda/repository/BaseRepositoryTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions src/test/java/eatda/repository/store/CheerRepositoryTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> imageKey = cheerRepository.findRecentImageKey(store);

assertThat(imageKey).isEmpty();
}
}
}
Loading
Loading