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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.souzip.application.location.dto.SearchPlace;
import com.souzip.application.location.required.PlaceSearchProvider;
import com.souzip.domain.shared.Coordinate;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -81,10 +82,27 @@ private SearchPlace convertToPlace(Result result) {
return new SearchPlace(
result.name(),
result.formattedAddress(),
extractRegion(result.plusCode()),
extractCategory(result.types()),
Coordinate.of(
BigDecimal.valueOf(result.geometry().location().lat()),
BigDecimal.valueOf(result.geometry().location().lng())
)
);
}

private String extractCategory(List<String> types) {
return Optional.ofNullable(types)
.filter(t -> !t.isEmpty())
.map(List::getFirst)
.orElse(null);
}

private String extractRegion(GooglePlacesSearchResponse.PlusCode plusCode) {
return Optional.ofNullable(plusCode)
.map(GooglePlacesSearchResponse.PlusCode::compoundCode)
.filter(code -> code.contains(" "))
.map(code -> code.substring(code.indexOf(" ") + 1))
.orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ public record Result(
@JsonProperty("formatted_address")
String formattedAddress,

Geometry geometry
Geometry geometry,

List<String> types,

@JsonProperty("plus_code")
PlusCode plusCode
) {}

@JsonIgnoreProperties(ignoreUnknown = true)
Expand All @@ -29,4 +34,10 @@ public record Location(
double lat,
double lng
) {}

@JsonIgnoreProperties(ignoreUnknown = true)
public record PlusCode(
@JsonProperty("compound_code")
String compoundCode
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import com.fasterxml.jackson.annotation.JsonInclude;
import com.souzip.application.location.dto.CitySearchResult;
import com.souzip.application.location.dto.SearchPlace;
import com.souzip.application.location.dto.PlaceSearchResult;
import com.souzip.application.location.dto.SearchPlace;
import com.souzip.application.location.dto.SearchResult;
import com.souzip.domain.city.entity.City;

import java.math.BigDecimal;
import com.souzip.domain.shared.Coordinate;
import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL)
Expand All @@ -16,9 +15,11 @@ public record SearchResponse(
String name,
String country,
String address,
BigDecimal latitude,
BigDecimal longitude
String region,
String category,
Coordinate coordinate
) {

public static List<SearchResponse> from(SearchResult result) {
if (result instanceof CitySearchResult(List<City> cities)) {
return cities.stream()
Expand All @@ -41,8 +42,9 @@ private static SearchResponse from(City city) {
city.getNameKr(),
city.getCountry().getNameKr(),
null,
city.getLatitude(),
city.getLongitude()
null,
null,
Coordinate.of(city.getLatitude(), city.getLongitude())

Choose a reason for hiding this comment

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

P2 Badge 도시 좌표를 Coordinate로 변환할 때 500을 유발하지 않게 하세요

from(City)에서 Coordinate.of(city.getLatitude(), city.getLongitude())를 호출하도록 바뀌면서, 범위를 벗어난 좌표 데이터가 있으면 IllegalStateException이 발생해 검색 API가 500으로 떨어집니다. 현재 도시 생성 경로는 CreateCityRequest/City.create에서 위경도 범위 검증이 없어 잘못된 값이 저장될 수 있으므로, 응답 변환 단계에서 예외를 터뜨리기보다 입력 단계에 범위 검증을 추가하거나(권장) 변환 실패를 안전하게 처리해야 합니다.

Useful? React with 👍 / 👎.

);
}

Expand All @@ -52,8 +54,9 @@ private static SearchResponse from(SearchPlace place) {
place.name(),
null,
place.address(),
place.coordinate().getLatitude(),
place.coordinate().getLongitude()
place.region(),
place.category(),
place.coordinate()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
public record SearchPlace(
String name,
String address,
String region,
String category,
Coordinate coordinate
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,44 @@ void searchByKeyword_Success() {
assertThat(places).hasSize(1);
assertThat(places.getFirst().name()).isEqualTo("에펠탑 기념품샵");
assertThat(places.getFirst().address()).isEqualTo("프랑스 파리 샹드마르스 에펠탑");
assertThat(places.getFirst().region()).isEqualTo("프랑스 파리 1구");
assertThat(places.getFirst().category()).isEqualTo("store");
assertThat(places.getFirst().coordinate().getLatitude()).isEqualTo(BigDecimal.valueOf(48.8584));
assertThat(places.getFirst().coordinate().getLongitude()).isEqualTo(BigDecimal.valueOf(2.2945));
}

@DisplayName("types가 null이면 category는 null로 반환한다")
@Test
void searchByKeyword_NullTypes() {
// given
GooglePlacesSearchResponse mockResponse = createMockResponseWithNullTypes();

when(restTemplate.getForObject(anyString(), eq(GooglePlacesSearchResponse.class)))
.thenReturn(mockResponse);

// when
List<SearchPlace> places = adapter.searchByKeyword("test");

// then
assertThat(places.getFirst().category()).isNull();
}

@DisplayName("plus_code가 null이면 region은 null로 반환한다")
@Test
void searchByKeyword_NullPlusCode() {
// given
GooglePlacesSearchResponse mockResponse = createMockResponseWithNullPlusCode();

when(restTemplate.getForObject(anyString(), eq(GooglePlacesSearchResponse.class)))
.thenReturn(mockResponse);

// when
List<SearchPlace> places = adapter.searchByKeyword("test");

// then
assertThat(places.getFirst().region()).isNull();
}

@DisplayName("상위 10개만 반환한다")
@Test
void searchByKeyword_LimitTo10() {
Expand All @@ -79,13 +113,11 @@ void searchByKeyword_LimitTo10() {
@Test
void searchByKeyword_NullResponse() {
// given
String keyword = "test";

when(restTemplate.getForObject(anyString(), eq(GooglePlacesSearchResponse.class)))
.thenReturn(null);

// when
List<SearchPlace> places = adapter.searchByKeyword(keyword);
List<SearchPlace> places = adapter.searchByKeyword("test");

// then
assertThat(places).isEmpty();
Expand All @@ -95,14 +127,13 @@ void searchByKeyword_NullResponse() {
@Test
void searchByKeyword_EmptyResults() {
// given
String keyword = "존재하지않는장소12345";
GooglePlacesSearchResponse emptyResponse = new GooglePlacesSearchResponse(List.of(), "ZERO_RESULTS");

when(restTemplate.getForObject(anyString(), eq(GooglePlacesSearchResponse.class)))
.thenReturn(emptyResponse);

// when
List<SearchPlace> places = adapter.searchByKeyword(keyword);
List<SearchPlace> places = adapter.searchByKeyword("존재하지않는장소12345");

// then
assertThat(places).isEmpty();
Expand All @@ -112,13 +143,11 @@ void searchByKeyword_EmptyResults() {
@Test
void searchByKeyword_ApiCallFails() {
// given
String keyword = "test";

when(restTemplate.getForObject(anyString(), eq(GooglePlacesSearchResponse.class)))
.thenThrow(new RuntimeException("API Error"));

// when
List<SearchPlace> places = adapter.searchByKeyword(keyword);
List<SearchPlace> places = adapter.searchByKeyword("test");

// then
assertThat(places).isEmpty();
Expand All @@ -127,15 +156,53 @@ void searchByKeyword_ApiCallFails() {
private GooglePlacesSearchResponse createMockResponse() {
GooglePlacesSearchResponse.Location location =
new GooglePlacesSearchResponse.Location(48.8584, 2.2945);

GooglePlacesSearchResponse.Geometry geometry =
new GooglePlacesSearchResponse.Geometry(location);

GooglePlacesSearchResponse.PlusCode plusCode =
new GooglePlacesSearchResponse.PlusCode("8FW4V75Q+GH 프랑스 파리 1구");
GooglePlacesSearchResponse.Result result =
new GooglePlacesSearchResponse.Result(
"에펠탑 기념품샵",
"프랑스 파리 샹드마르스 에펠탑",
geometry
geometry,
List.of("store", "point_of_interest", "establishment"),
plusCode
);

return new GooglePlacesSearchResponse(List.of(result), "OK");
}

private GooglePlacesSearchResponse createMockResponseWithNullTypes() {
GooglePlacesSearchResponse.Location location =
new GooglePlacesSearchResponse.Location(48.8584, 2.2945);
GooglePlacesSearchResponse.Geometry geometry =
new GooglePlacesSearchResponse.Geometry(location);
GooglePlacesSearchResponse.PlusCode plusCode =
new GooglePlacesSearchResponse.PlusCode("8FW4V75Q+GH 프랑스 파리 1구");
GooglePlacesSearchResponse.Result result =
new GooglePlacesSearchResponse.Result(
"테스트 장소",
"테스트 주소",
geometry,
null,
plusCode
);

return new GooglePlacesSearchResponse(List.of(result), "OK");
}

private GooglePlacesSearchResponse createMockResponseWithNullPlusCode() {
GooglePlacesSearchResponse.Location location =
new GooglePlacesSearchResponse.Location(48.8584, 2.2945);
GooglePlacesSearchResponse.Geometry geometry =
new GooglePlacesSearchResponse.Geometry(location);
GooglePlacesSearchResponse.Result result =
new GooglePlacesSearchResponse.Result(
"테스트 장소",
"테스트 주소",
geometry,
List.of("cafe"),
null
);

return new GooglePlacesSearchResponse(List.of(result), "OK");
Expand All @@ -146,14 +213,17 @@ private GooglePlacesSearchResponse createMockResponseWith20Results() {
.mapToObj(i -> {
GooglePlacesSearchResponse.Location location =
new GooglePlacesSearchResponse.Location(37.498 + i * 0.001, 127.028 + i * 0.001);

GooglePlacesSearchResponse.Geometry geometry =
new GooglePlacesSearchResponse.Geometry(location);
GooglePlacesSearchResponse.PlusCode plusCode =
new GooglePlacesSearchResponse.PlusCode("GG3V+" + i + " 서울특별시 강남구");

return new GooglePlacesSearchResponse.Result(
"카페 " + (i + 1),
"서울 강남구 테헤란로 " + (i + 1),
geometry
geometry,
List.of("cafe", "establishment"),
plusCode
);
})
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ void getAddress() throws Exception {
fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"),
fieldWithPath("data.formattedAddress").type(JsonFieldType.STRING)
.description("전체 주소 (Deprecated: address 필드 사용 권장)"),
fieldWithPath("data.address").type(JsonFieldType.STRING)
.description("전체 주소"),
fieldWithPath("data.address").type(JsonFieldType.STRING).description("전체 주소"),
fieldWithPath("data.city").type(JsonFieldType.STRING).description("도시 이름"),
fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional()
Expand Down Expand Up @@ -141,8 +140,8 @@ void searchCity() throws Exception {
.andExpect(jsonPath("$.data[0].type").value("city"))
.andExpect(jsonPath("$.data[0].name").value("서울"))
.andExpect(jsonPath("$.data[0].country").value("대한민국"))
.andExpect(jsonPath("$.data[0].latitude").value(37.5665))
.andExpect(jsonPath("$.data[0].longitude").value(126.9780))
.andExpect(jsonPath("$.data[0].coordinate.latitude").value(37.5665))
.andExpect(jsonPath("$.data[0].coordinate.longitude").value(126.9780))
.andDo(document("location/search-city",
getDocumentRequest(),
getDocumentResponse(),
Expand All @@ -155,8 +154,9 @@ void searchCity() throws Exception {
fieldWithPath("data[].name").type(JsonFieldType.STRING).description("도시명 또는 장소명"),
fieldWithPath("data[].country").type(JsonFieldType.STRING).description("국가명 (도시 검색 시)").optional(),
fieldWithPath("data[].address").type(JsonFieldType.STRING).description("주소 (장소 검색 시)").optional(),
fieldWithPath("data[].latitude").type(JsonFieldType.NUMBER).description("위도"),
fieldWithPath("data[].longitude").type(JsonFieldType.NUMBER).description("경도"),
fieldWithPath("data[].coordinate").type(JsonFieldType.OBJECT).description("좌표"),
fieldWithPath("data[].coordinate.latitude").type(JsonFieldType.NUMBER).description("위도"),
fieldWithPath("data[].coordinate.longitude").type(JsonFieldType.NUMBER).description("경도"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional()
)
));
Expand All @@ -171,6 +171,8 @@ void searchPlace() throws Exception {
SearchPlace eiffelTower = new SearchPlace(
"Eiffel Tower",
"Champ de Mars, 5 Avenue Anatole France, 75007 Paris, France",
"프랑스 파리",
"tourist_attraction",
Coordinate.of(BigDecimal.valueOf(48.8584), BigDecimal.valueOf(2.2945))
);

Expand All @@ -188,8 +190,10 @@ void searchPlace() throws Exception {
.andExpect(jsonPath("$.data[0].type").value("place"))
.andExpect(jsonPath("$.data[0].name").value("Eiffel Tower"))
.andExpect(jsonPath("$.data[0].address").value("Champ de Mars, 5 Avenue Anatole France, 75007 Paris, France"))
.andExpect(jsonPath("$.data[0].latitude").value(48.8584))
.andExpect(jsonPath("$.data[0].longitude").value(2.2945))
.andExpect(jsonPath("$.data[0].region").value("프랑스 파리"))
.andExpect(jsonPath("$.data[0].category").value("tourist_attraction"))
.andExpect(jsonPath("$.data[0].coordinate.latitude").value(48.8584))
.andExpect(jsonPath("$.data[0].coordinate.longitude").value(2.2945))
.andDo(document("location/search-place",
getDocumentRequest(),
getDocumentResponse(),
Expand All @@ -202,8 +206,11 @@ void searchPlace() throws Exception {
fieldWithPath("data[].name").type(JsonFieldType.STRING).description("도시명 또는 장소명"),
fieldWithPath("data[].country").type(JsonFieldType.STRING).description("국가명 (도시 검색 시)").optional(),
fieldWithPath("data[].address").type(JsonFieldType.STRING).description("주소 (장소 검색 시)").optional(),
fieldWithPath("data[].latitude").type(JsonFieldType.NUMBER).description("위도"),
fieldWithPath("data[].longitude").type(JsonFieldType.NUMBER).description("경도"),
fieldWithPath("data[].region").type(JsonFieldType.STRING).description("지역명 (장소 검색 시)").optional(),
fieldWithPath("data[].category").type(JsonFieldType.STRING).description("카테고리 (장소 검색 시)").optional(),
fieldWithPath("data[].coordinate").type(JsonFieldType.OBJECT).description("좌표"),
fieldWithPath("data[].coordinate.latitude").type(JsonFieldType.NUMBER).description("위도"),
fieldWithPath("data[].coordinate.longitude").type(JsonFieldType.NUMBER).description("경도"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional()
)
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ void searchMultiplePlaces() {
}

private City createCity(String nameKr, String nameEn) {
Country country = createCountry("일본", "Japan");
Country country = createCountry();

return City.create(
nameEn,
Expand All @@ -192,10 +192,10 @@ private City createCity(String nameKr, String nameEn) {
);
}

private Country createCountry(String nameKr, String nameEn) {
private Country createCountry() {
return Country.of(
nameEn,
nameKr,
"Japan",
"일본",
"JP",
"Tokyo",
Region.ASIA,
Expand All @@ -210,6 +210,8 @@ private SearchPlace createPlace(String name, String address) {
return new SearchPlace(
name,
address,
null,
null,
Coordinate.of(
BigDecimal.valueOf(48.8584),
BigDecimal.valueOf(2.2945)
Expand Down