diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/EstateApi.java b/src/main/java/com/zipte/platform/server/adapter/in/web/EstateApi.java index dc22e31..e7f3461 100644 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/EstateApi.java +++ b/src/main/java/com/zipte/platform/server/adapter/in/web/EstateApi.java @@ -5,11 +5,13 @@ import com.zipte.platform.core.response.pageable.PageResponse; import com.zipte.platform.server.adapter.in.web.dto.response.EstateDetailResponse; import com.zipte.platform.server.adapter.in.web.dto.response.EstateListResponse; +import com.zipte.platform.server.adapter.in.web.dto.response.EstatePriceListResponse; import com.zipte.platform.server.adapter.in.web.swagger.EstateApiSpec; +import com.zipte.platform.server.application.in.estate.EstatePriceUseCase; import com.zipte.platform.server.application.in.estate.GetEstateUseCase; import com.zipte.platform.server.application.in.external.OpenAiUseCase; import com.zipte.platform.server.domain.estate.Estate; -import jakarta.persistence.EntityNotFoundException; +import com.zipte.platform.server.domain.estate.EstatePrice; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,6 +27,9 @@ public class EstateApi implements EstateApiSpec { private final GetEstateUseCase getService; + /// 가격 의존성 + private final EstatePriceUseCase priceService; + /// AI 의존성 private final OpenAiUseCase openAiService; @@ -37,13 +42,11 @@ public ApiResponse getEstate( Estate estate; if (code != null) { - estate = getService.loadEstateByCode(code) - .orElseThrow(() -> new EntityNotFoundException("해당하는 아파트가 존재하지 않습니다.")); + estate = getService.loadEstateByCode(code); } else if (name != null) { - estate = getService.loadEstateByName(name) - .orElseThrow(() -> new EntityNotFoundException("해당하는 아파트가 존재하지 않습니다.")); + estate = getService.loadEstateByName(name); } else { - throw new IllegalArgumentException("최소 하나 이상의 요청 파라미터가 필요합니다."); + throw new IllegalArgumentException(); } return ApiResponse.created(EstateDetailResponse.from(estate)); @@ -74,9 +77,8 @@ public ApiResponse> getEstateByLocation( @RequestParam(value = "longitude") double longitude, @RequestParam(value = "latitude") double latitude, @RequestParam(value = "radius") double radius) { - List list = getService.loadEstatesNearBy(longitude, latitude, radius); - return ApiResponse.ok(EstateListResponse.from(list)); + return ApiResponse.ok(getService.loadEstatesNearBy(longitude, latitude, radius)); } @@ -93,6 +95,16 @@ public ApiResponse> getEstateByLocationByKaptCode( } + @GetMapping("/compare") + public ApiResponse> getEstateByCompare( + @RequestParam(value = "first") String first, + @RequestParam(value = "second") String second + ) { + List estates = getService.loadEstatesByCompare(List.of(first, second)); + + return ApiResponse.ok(EstateDetailResponse.from(estates)); + } + /// AI 기반 특징 요약 @GetMapping("/ai/{kaptCode}") @@ -101,4 +113,25 @@ public ApiResponse getEstateDetail(@PathVariable String kaptCode) { return ApiResponse.ok(result); } + + + /// 가격 조회 + @GetMapping("/price") + public ApiResponse> getPriceByCodeAndArea( + @RequestParam String kaptCode, + @RequestParam double area) { + + List list = priceService.getEstatePriceByCode(kaptCode, area); + + return ApiResponse.ok(EstatePriceListResponse.from(list)); + } + + + @GetMapping("/price/{kaptCode}") + public ApiResponse> getPrice(@PathVariable String kaptCode) { + + List list = priceService.getEstatePriceByCode(kaptCode); + + return ApiResponse.ok(EstatePriceListResponse.from(list)); + } } diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/EstatePriceApi.java b/src/main/java/com/zipte/platform/server/adapter/in/web/EstatePriceApi.java deleted file mode 100644 index e5fefc7..0000000 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/EstatePriceApi.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.zipte.platform.server.adapter.in.web; - -import com.zipte.platform.core.response.ApiResponse; -import com.zipte.platform.server.adapter.in.web.dto.response.EstatePriceListResponse; -import com.zipte.platform.server.adapter.in.web.swagger.EstatePriceApiSpec; -import com.zipte.platform.server.application.in.estate.EstatePriceUseCase; -import com.zipte.platform.server.domain.estate.EstatePrice; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/estate/price") -@RequiredArgsConstructor -public class EstatePriceApi implements EstatePriceApiSpec { - - private final EstatePriceUseCase priceService; - - - @GetMapping("/{kaptCode}") - public ApiResponse> getPrice(@PathVariable String kaptCode) { - - List list = priceService.getEstatePriceByCode(kaptCode); - - return ApiResponse.ok(EstatePriceListResponse.from(list)); - } - - @GetMapping() - public ApiResponse> getPriceByCodeAndArea( - @RequestParam String kaptCode, - @RequestParam double area) { - - List list = priceService.getEstatePriceByCode(kaptCode, area); - - return ApiResponse.ok(EstatePriceListResponse.from(list)); - } - -} diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateDetailResponse.java b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateDetailResponse.java index 017153c..81ec7b5 100644 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateDetailResponse.java +++ b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateDetailResponse.java @@ -4,6 +4,8 @@ import lombok.Builder; import lombok.Data; +import java.util.*; + @Data @Builder public class EstateDetailResponse { @@ -34,4 +36,11 @@ public static EstateDetailResponse from(Estate estate) { .facility(EstateFacilityResponse.from(estate)) .build(); } + + public static List from(List estates) { + return estates.stream() + .map(EstateDetailResponse::from) + .toList(); + } + } diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateListResponse.java b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateListResponse.java index cde67bc..df27d12 100644 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateListResponse.java +++ b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstateListResponse.java @@ -2,14 +2,16 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.zipte.platform.server.domain.estate.Estate; +import com.zipte.platform.server.domain.estate.EstatePrice; import lombok.Builder; import lombok.Data; import java.util.List; +import java.util.Optional; @Data @Builder -@JsonInclude(JsonInclude.Include.NON_NULL) // NULL 값인 필드는 JSON 응답에서 +@JsonInclude(JsonInclude.Include.NON_NULL) // NULL 값인 필드는 JSON 응답에서 제외 public class EstateListResponse { // 아파트 기본 정보 @@ -20,6 +22,9 @@ public class EstateListResponse { // 매물 개수 private Integer propertyCount; + // 최근 거래 조회 + private EstatePriceDetailResponse price; + // 좌표 private EstateLocationResponse location; @@ -43,8 +48,21 @@ public static EstateListResponse from(Estate estate, int count) { .build(); } + /// 아파트 정보와 가격을 동시에 보여주는 로직 + public static EstateListResponse from(Estate estate, Optional price) { + return EstateListResponse.builder() + .complexCode(estate.getKaptCode()) + .complexName(estate.getKaptName()) + .address(estate.getKaptAddr()) + .price(EstatePriceDetailResponse.from(price)) + .location(EstateLocationResponse.from(estate)) + .build(); + } + public static List from(List estates) { - return estates.stream().map(EstateListResponse::from).toList(); + return estates.stream() + .map(EstateListResponse::from) + .toList(); } diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstatePriceDetailResponse.java b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstatePriceDetailResponse.java new file mode 100644 index 0000000..728a7e0 --- /dev/null +++ b/src/main/java/com/zipte/platform/server/adapter/in/web/dto/response/EstatePriceDetailResponse.java @@ -0,0 +1,29 @@ +package com.zipte.platform.server.adapter.in.web.dto.response; + +import com.zipte.platform.server.domain.estate.EstatePrice; +import lombok.Builder; + +import java.util.Optional; + +@Builder +public record EstatePriceDetailResponse( + double exclusiveArea, String price, String transactionDate) { + + public static EstatePriceDetailResponse from(Optional estatePrice) { + if (estatePrice.isEmpty()) { + return EstatePriceDetailResponse.builder() + .exclusiveArea(0.0) + .price("없음") + .transactionDate("없음") + .build(); + } + + EstatePrice price = estatePrice.get(); + return EstatePriceDetailResponse.builder() + .exclusiveArea(price.getExclusiveArea() != 0.0 ? price.getExclusiveArea() : 0.0) + .price(price.getPrice() != null ? price.getPrice() : "없음") + .transactionDate(price.getTransactionDate() != null ? price.getTransactionDate() : "없음") + .build(); + } + +} diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstateApiSpec.java b/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstateApiSpec.java index e07c772..424b095 100644 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstateApiSpec.java +++ b/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstateApiSpec.java @@ -5,6 +5,7 @@ import com.zipte.platform.core.response.pageable.PageResponse; import com.zipte.platform.server.adapter.in.web.dto.response.EstateDetailResponse; import com.zipte.platform.server.adapter.in.web.dto.response.EstateListResponse; +import com.zipte.platform.server.adapter.in.web.dto.response.EstatePriceListResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -45,10 +46,10 @@ ApiResponse> getEstateByRegion( description = "지정한 경도, 위도, 반경 값으로부터 근처에 위치한 아파트 목록을 반환합니다." ) ApiResponse> getEstateByLocation( - @Parameter(description = "경도", required = true, example = "127.029704677041") + @Parameter(description = "경도", required = true, example = "127.118904") @RequestParam(value = "longitude") double longitude, - @Parameter(description = "위도", required = true, example = "37.498266842599") + @Parameter(description = "위도", required = true, example = "37.382698") @RequestParam(value = "latitude") double latitude, @Parameter(description = "반경 (km 단위)", required = true, example = "1") @@ -60,10 +61,10 @@ ApiResponse> getEstateByLocation( description = "지정한 좌표와 반경을 기준으로 근처의 아파트 및 해당 아파트에 등록된 매물 정보를 함께 조회합니다." ) ApiResponse> getEstateByLocationByKaptCode( - @Parameter(description = "경도", required = true, example = "127.029704677041") + @Parameter(description = "경도", required = true, example = "127.118904") @RequestParam(value = "longitude") double longitude, - @Parameter(description = "위도", required = true, example = "37.498266842599") + @Parameter(description = "위도", required = true, example = "37.382698") @RequestParam(value = "latitude") double latitude, @Parameter(description = "반경 (km 단위)", required = true, example = "1.0") @@ -77,4 +78,39 @@ ApiResponse> getEstateByLocationByKaptCode( ApiResponse getEstateDetail( @Parameter(description = "아파트 코드", required = true, example = "A46378823") @PathVariable String kaptCode); + + + + @Operation( + summary = "아파트 가격 정보 조회", + description = "아파트 코드를 통해 가격 정보를 조회합니다." + ) + ApiResponse> getPrice( + @Parameter(description = "아파트 코드", required = true, example = "A46392821") + @PathVariable String kaptCode); + + + + @Operation( + summary = "아파트 가격 정보 조회", + description = "아파트 코드와 평수를 통해 가격 정보를 조회합니다." + ) + ApiResponse> getPriceByCodeAndArea( + @Parameter(description = "아파트 코드", required = true, example = "A46392821") + @RequestParam String kaptCode, + + @Parameter(description = "면적", required = true, example = "83.73") + @RequestParam double area); + + + @Operation( + summary = "아파트 비교 조회", + description = "아파트 코드를 통해 2개의 아파트를 비교 조회합니다." + ) + ApiResponse> getEstateByCompare( + @Parameter(description = "비교할 아파트 코드 (1)", required = true, example = "A46392821") + @RequestParam(value = "first") String first, + + @Parameter(description = "비교할 아파트 코드 (2)", required = true, example = "A46378823") + @RequestParam(value = "second") String second); } diff --git a/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstatePriceApiSpec.java b/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstatePriceApiSpec.java deleted file mode 100644 index 1dbb4ea..0000000 --- a/src/main/java/com/zipte/platform/server/adapter/in/web/swagger/EstatePriceApiSpec.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.zipte.platform.server.adapter.in.web.swagger; - -import com.zipte.platform.core.response.ApiResponse; -import com.zipte.platform.server.adapter.in.web.dto.response.EstatePriceListResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; - -import java.util.List; - -@Tag(name = "아파트 실거래가 API", description = "아파트 가격 관련 조회 API") -public interface EstatePriceApiSpec { - - - @Operation( - summary = "아파트 가격 정보 조회", - description = "아파트 코드를 통해 가격 정보를 조회합니다." - ) - ApiResponse> getPrice( - @Parameter(description = "아파트 코드", required = true, example = "A46392821") - @PathVariable String kaptCode); - - - @Operation( - summary = "아파트 가격 정보 조회", - description = "아파트 코드와 평수를 통해 가격 정보를 조회합니다." - ) - ApiResponse> getPriceByCodeAndArea( - @Parameter(description = "아파트 코드", required = true, example = "A46392821") - @RequestParam String kaptCode, - - @Parameter(description = "면적", required = true, example = "83.73") - @RequestParam double area); - -} - diff --git a/src/main/java/com/zipte/platform/server/adapter/out/EstatePricePersistenceAdapter.java b/src/main/java/com/zipte/platform/server/adapter/out/EstatePricePersistenceAdapter.java index 4c5cb72..01803cd 100644 --- a/src/main/java/com/zipte/platform/server/adapter/out/EstatePricePersistenceAdapter.java +++ b/src/main/java/com/zipte/platform/server/adapter/out/EstatePricePersistenceAdapter.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.Optional; @Component @RequiredArgsConstructor @@ -15,6 +16,12 @@ public class EstatePricePersistenceAdapter implements EstatePricePort { private final EstatePriceMongoRepository repository; + @Override + public Optional loadRecentPriceByKaptCode(String kaptCode) { + return repository.findFirstByKaptCodeOrderByTransactionDateDesc(kaptCode) + .map(EstatePriceDocument::toDomain); + } + @Override public List loadAllEstatePrices(String kaptCode) { return repository.findAllByKaptCode(kaptCode).stream() @@ -24,7 +31,7 @@ public List loadAllEstatePrices(String kaptCode) { @Override public List loadEstatePriceByCodeAndArea(String kaptCode, double exclusiveArea) { - return repository.findALlByKaptCodeAndExclusiveArea(kaptCode, exclusiveArea).stream() + return repository.findAllByKaptCodeAndExclusiveArea(kaptCode, exclusiveArea).stream() .map(EstatePriceDocument::toDomain) .toList(); } diff --git a/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstateMongoRepository.java b/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstateMongoRepository.java index 001b5b4..9bc97a7 100644 --- a/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstateMongoRepository.java +++ b/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstateMongoRepository.java @@ -28,4 +28,7 @@ public interface EstateMongoRepository extends MongoRepository findByLocation(double longitude, double latitude, double radiusInRadians); boolean existsByKaptCode(String kaptCode); + + /// 테스트용 + void deleteByKaptCode(String kaptCode); } diff --git a/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstatePriceMongoRepository.java b/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstatePriceMongoRepository.java index 890da34..b8d9f44 100644 --- a/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstatePriceMongoRepository.java +++ b/src/main/java/com/zipte/platform/server/adapter/out/mongo/estate/EstatePriceMongoRepository.java @@ -3,11 +3,14 @@ import org.springframework.data.mongodb.repository.MongoRepository; import java.util.List; +import java.util.Optional; public interface EstatePriceMongoRepository extends MongoRepository { List findAllByKaptCode(String kaptCode); - List findALlByKaptCodeAndExclusiveArea(String kaptCode, double exclusiveArea); + List findAllByKaptCodeAndExclusiveArea(String kaptCode, double exclusiveArea); + + Optional findFirstByKaptCodeOrderByTransactionDateDesc(String kaptCode); } diff --git a/src/main/java/com/zipte/platform/server/application/in/estate/GetEstateUseCase.java b/src/main/java/com/zipte/platform/server/application/in/estate/GetEstateUseCase.java index 3a23843..2ab246d 100644 --- a/src/main/java/com/zipte/platform/server/application/in/estate/GetEstateUseCase.java +++ b/src/main/java/com/zipte/platform/server/application/in/estate/GetEstateUseCase.java @@ -15,20 +15,21 @@ public interface GetEstateUseCase { */ // 특정 좌표 주변의 아파트 가져오기 - List loadEstatesNearBy(double latitude, double longitude, double radiusInKm); + List loadEstatesNearBy(double latitude, double longitude, double radiusInKm); // 특정 좌표 근처에서의 매물이 있는 아파트만 뜨도록 List loadEstatesNearByProperty(double latitude, double longitude, double radiusInKm); // 코드를 바탕으로 아파트 가져오기 - Optional loadEstateByCode(String kaptCode); + Estate loadEstateByCode(String kaptCode); // 이름을 바탕으로 아파트 가져오기 - Optional loadEstateByName(String kaptName); + Estate loadEstateByName(String kaptName); // 특정 지역(동)을 포함하는 아파트 목록 페이징 조회 Page loadEstatesByRegion(String region, Pageable pageable); - + // 원하는 아파트 비교하기 + List loadEstatesByCompare(List kaptCodes); } diff --git a/src/main/java/com/zipte/platform/server/application/out/estate/EstatePricePort.java b/src/main/java/com/zipte/platform/server/application/out/estate/EstatePricePort.java index 10da6d7..c6cd300 100644 --- a/src/main/java/com/zipte/platform/server/application/out/estate/EstatePricePort.java +++ b/src/main/java/com/zipte/platform/server/application/out/estate/EstatePricePort.java @@ -3,9 +3,13 @@ import com.zipte.platform.server.domain.estate.EstatePrice; import java.util.List; +import java.util.Optional; public interface EstatePricePort { + /// 가장 최근의 거래 조회하기 + Optional loadRecentPriceByKaptCode(String kaptCode); + /// 목록 조회하기 List loadAllEstatePrices(String kaptCode); diff --git a/src/main/java/com/zipte/platform/server/application/service/EstateService.java b/src/main/java/com/zipte/platform/server/application/service/EstateService.java index 1ca02bd..5aab102 100644 --- a/src/main/java/com/zipte/platform/server/application/service/EstateService.java +++ b/src/main/java/com/zipte/platform/server/application/service/EstateService.java @@ -1,19 +1,25 @@ package com.zipte.platform.server.application.service; +import com.zipte.platform.core.response.ErrorCode; import com.zipte.platform.server.adapter.in.web.dto.response.EstateListResponse; import com.zipte.platform.server.application.in.estate.GetEstateUseCase; +import com.zipte.platform.server.application.out.estate.EstatePricePort; import com.zipte.platform.server.application.out.estate.LoadEstatePort; import com.zipte.platform.server.application.out.property.LoadPropertyPort; import com.zipte.platform.server.domain.estate.Estate; +import com.zipte.platform.server.domain.estate.EstatePrice; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; +import java.util.NoSuchElementException; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class EstateService implements GetEstateUseCase { @@ -22,10 +28,28 @@ public class EstateService implements GetEstateUseCase { private final LoadPropertyPort loadPropertyPort; + /// 아파트 가격 조회 + private final EstatePricePort pricePort; + + @Override - public List loadEstatesNearBy(double latitude, double longitude, double radiusInKm) { + public List loadEstatesNearBy(double latitude, double longitude, double radiusInKm) { double radiusInRadians = radiusInKm / 6378.1; // 반경 km -> radians 변환 - return loadPort.loadEstatesNearBy(latitude, longitude, radiusInRadians); + + /// 좌표 근처의 아파트 가져오기 + List estates = loadPort.loadEstatesNearBy(latitude, longitude, radiusInRadians); + + List responses = new ArrayList<>(); + + /// 최신의 거래 목록 가져오기 + estates.forEach(estate -> { + Optional price = pricePort.loadRecentPriceByKaptCode(estate.getKaptCode()); + EstateListResponse response = EstateListResponse.from(estate, price); + responses.add(response); + }); + + return responses; + } @Override @@ -48,13 +72,15 @@ public List loadEstatesNearByProperty(double latitude, doubl @Override - public Optional loadEstateByCode(String kaptCode) { - return loadPort.loadEstateByCode(kaptCode); + public Estate loadEstateByCode(String kaptCode) { + return loadPort.loadEstateByCode(kaptCode) + .orElseThrow(() -> new NoSuchElementException(ErrorCode.NOT_ESTATE.getMessage())); } @Override - public Optional loadEstateByName(String kaptName) { - return loadPort.loadEstateByName(kaptName); + public Estate loadEstateByName(String kaptName) { + return loadPort.loadEstateByName(kaptName) + .orElseThrow(() -> new NoSuchElementException(ErrorCode.NOT_ESTATE.getMessage())); } @Override @@ -62,4 +88,19 @@ public Page loadEstatesByRegion(String region, Pageable pageable) { return loadPort.loadEstatesByRegion(region, pageable); } + /// 아파트를 서로 비교하기 + @Override + public List loadEstatesByCompare(List kaptCodes) { + + /// 예외처리를 통한 아파트 가져오기 + Estate first = loadPort.loadEstateByCode(kaptCodes.get(0)) + .orElseThrow(() -> new NoSuchElementException("first" + ErrorCode.NOT_ESTATE.getMessage())); + + Estate second = loadPort.loadEstateByCode(kaptCodes.get(1)) + .orElseThrow(() -> new NoSuchElementException("second" + ErrorCode.NOT_ESTATE.getMessage())); + + return List.of(first, second); + + } + } diff --git a/src/test/java/com/zipte/platform/server/adapter/in/web/EstateApiTest.java b/src/test/java/com/zipte/platform/server/adapter/in/web/EstateApiTest.java new file mode 100644 index 0000000..9314075 --- /dev/null +++ b/src/test/java/com/zipte/platform/server/adapter/in/web/EstateApiTest.java @@ -0,0 +1,272 @@ +package com.zipte.platform.server.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.zipte.platform.core.response.ApiResponse; +import com.zipte.platform.core.response.pageable.PageRequest; +import com.zipte.platform.core.response.pageable.PageResponse; +import com.zipte.platform.server.adapter.in.web.dto.response.EstateDetailResponse; +import com.zipte.platform.server.adapter.in.web.dto.response.EstateListResponse; +import com.zipte.platform.server.application.in.estate.EstatePriceUseCase; +import com.zipte.platform.server.application.in.estate.GetEstateUseCase; +import com.zipte.platform.server.application.in.external.OpenAiUseCase; +import com.zipte.platform.server.domain.estate.Estate; +import com.zipte.platform.server.domain.estate.EstateFixtures; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.bind.MethodArgumentNotValidException; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.*; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class EstateApiTest { + + + @MockitoBean + private GetEstateUseCase getService; + + @MockitoBean + private EstatePriceUseCase priceService; + + @MockitoBean + private OpenAiUseCase openAiService; + + @Autowired + private MockMvc mockMvc; + + private ObjectMapper objectMapper; + + @BeforeEach + public void init() { + objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + } + + @Nested + @DisplayName("아파트 조회") + class Get { + + @Test + @DisplayName("[happy] Code를 바탕으로 정상적으로 아파트를 조회한다.") + void loadByCode_happy() throws Exception { + + // Given + String code = "test_code"; + Estate stub = EstateFixtures.stub(); + + given(getService.loadEstateByCode(code)) + .willReturn(stub); + + ApiResponse response = ApiResponse.ok(EstateDetailResponse.from(stub)); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate") + .param("code", code) + .contentType(MediaType.APPLICATION_JSON)); + + // Then + MvcResult mvcResult = resultActions + .andExpect(status().isOk()) + .andDo(print()) + .andReturn(); + + + String expectResponse = objectMapper.writeValueAsString(response); + mvcResult.getResponse().setCharacterEncoding("UTF-8"); + String responseBody = mvcResult.getResponse().getContentAsString(); + + Assertions.assertThat(response).isNotNull(); + Assertions.assertThat(responseBody).isEqualTo(expectResponse); + + + } + + + @Test + @DisplayName("[happy] 이름을 바탕으로 정상적으로 아파트를 조회한다.") + void loadByName_happy() throws Exception { + + // Given + String name = "테스트아파트"; + Estate stub = EstateFixtures.stub(); // 고정된 stub 객체 + given(getService.loadEstateByName(name)) + .willReturn(stub); + + ApiResponse response = ApiResponse.created(EstateDetailResponse.from(stub)); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate") + .param("name", name) + .contentType(MediaType.APPLICATION_JSON)); + + // Then + MvcResult mvcResult = resultActions + .andExpect(status().isOk()) + .andDo(print()) + .andReturn(); + + String expectedResponse = objectMapper.writeValueAsString(response); + mvcResult.getResponse().setCharacterEncoding("UTF-8"); + String actualResponse = mvcResult.getResponse().getContentAsString(); + + Assertions.assertThat(actualResponse).isEqualTo(expectedResponse); + } + + @Test + @DisplayName("[happy] 특정 지역의 아파트 리스트를 정상적으로 조회한다.") + void getEstateByRegion_happy() throws Exception { + // Given + String region = "서울시 강남구"; + PageRequest pageRequest = new PageRequest(1, 10); + Pageable pageable = org.springframework.data.domain.PageRequest.of(1, 10); + List content = List.of(EstateFixtures.stub()); + + PageImpl page = new PageImpl<>(content, pageable, 1); + + given(getService.loadEstatesByRegion(eq(region), any(Pageable.class))) + .willReturn(page); + + + List dtos = EstateListResponse.from(content); + ApiResponse> response = ApiResponse.ok( + new PageResponse<>(dtos, pageRequest, page.getTotalElements()) + ); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate/list") + .param("region", region) + .contentType(MediaType.APPLICATION_JSON) + ); + + // Then + MvcResult result = resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.dtoList[0].complexCode").value("TEST-KAPT-CODE-1234")) + .andExpect(jsonPath("$.data.pageRequest.page").value(1)) + .andExpect(jsonPath("$.data.pageNumList[0]").value(1)) + .andDo(print()) + .andReturn(); + } + + @Test + @DisplayName("[happy] 특정 좌표 반경 내 아파트 목록을 정상적으로 조회한다.") + void getEstateByLocation_happy() throws Exception { + // Given + double longitude = 127.0; + double latitude = 37.5; + double radius = 1.0; + + List expectedList = EstateListResponse.from(List.of(EstateFixtures.stub())); + given(getService.loadEstatesNearBy(longitude, latitude, radius)).willReturn(expectedList); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate/list/location") + .param("longitude", String.valueOf(longitude)) + .param("latitude", String.valueOf(latitude)) + .param("radius", String.valueOf(radius)) + .contentType(MediaType.APPLICATION_JSON) + ); + + // Then + MvcResult result = resultActions.andExpect(status().isOk()).andDo(print()).andReturn(); + String expected = objectMapper.writeValueAsString(ApiResponse.ok(expectedList)); + String actual = result.getResponse().getContentAsString(); + + Assertions.assertThat(actual).isEqualTo(expected); + } + + + @Test + @DisplayName("[happy] AI를 통한 아파트 요약 정보를 정상적으로 조회한다.") + void getEstateDetailAI_happy() throws Exception { + // Given + String kaptCode = "APT1234"; + String aiSummary = "이 아파트는 좋은 입지와 편리한 교통을 갖추고 있습니다."; + + given(openAiService.getKaptCharacteristic(kaptCode)) + .willReturn(aiSummary); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate/ai/" + kaptCode) + .contentType(MediaType.APPLICATION_JSON) + ); + + // Then + MvcResult result = resultActions.andExpect(status().isOk()).andDo(print()).andReturn(); + String expected = objectMapper.writeValueAsString(ApiResponse.ok(aiSummary)); + String actual = result.getResponse().getContentAsString(); + + Assertions.assertThat(actual) + .isEqualTo(expected); + } + + @Test + @DisplayName("[happy] 두 개의 아파트를 비교 조회한다.") + void getEstateByCompare_happy() throws Exception { + // Given + String first = "APT1"; + String second = "APT2"; + List estates = List.of(EstateFixtures.stub(), EstateFixtures.stub()); + + given(getService.loadEstatesByCompare(List.of(first, second))).willReturn(estates); + + List responseList = EstateDetailResponse.from(estates); + + // When + ResultActions resultActions = mockMvc.perform( + MockMvcRequestBuilders + .get("/api/v1/estate/compare") + .param("first", first) + .param("second", second) + .contentType(MediaType.APPLICATION_JSON) + ); + + // Then + MvcResult result = resultActions.andExpect(status().isOk()).andDo(print()).andReturn(); + String expected = objectMapper.writeValueAsString(ApiResponse.ok(responseList)); + String actual = result.getResponse().getContentAsString(); + + Assertions.assertThat(actual).isEqualTo(expected); + } + + + + } + +} diff --git a/src/test/java/com/zipte/platform/server/adapter/out/EstatePersistenceAdapterTest.java b/src/test/java/com/zipte/platform/server/adapter/out/EstatePersistenceAdapterTest.java new file mode 100644 index 0000000..56a995d --- /dev/null +++ b/src/test/java/com/zipte/platform/server/adapter/out/EstatePersistenceAdapterTest.java @@ -0,0 +1,183 @@ +package com.zipte.platform.server.adapter.out; + +import com.zipte.platform.server.adapter.out.mongo.estate.EstateDocument; +import com.zipte.platform.server.adapter.out.mongo.estate.EstateMongoRepository; +import com.zipte.platform.server.domain.estate.Estate; +import com.zipte.platform.server.domain.estate.EstateFixtures; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class EstatePersistenceAdapterTest { + + @Autowired + private EstateMongoRepository repository; + + @Autowired + private EstatePersistenceAdapter sut; + + @BeforeEach + void setUp() { + + /// 테스트 값을 넣어둔다. + Estate stub = EstateFixtures.stub(); + + /// 기존 값 삭제 + repository.deleteByKaptCode(stub.getKaptCode()); + + /// 객체 생성 + EstateDocument document = EstateDocument.from(stub); + + /// DB에 해당 값을 저장한다. + repository.save(document); + } + + @Nested + @DisplayName("조회 테스트") + class Load { + + @Test + @DisplayName("[happy] 정상 조회") + void load_happy() { + + // Given + Estate stub = EstateFixtures.stub(); + String kaptCode = stub.getKaptCode(); + + // When + Optional estate = sut.loadEstateByCode(kaptCode); + Estate result = estate.get(); + + // Then + Assertions.assertThat(repository.findByKaptCode(kaptCode)) + .isNotEmpty(); + + Assertions.assertThat(result.getKaptCode()).isEqualTo(stub.getKaptCode()); + } + + @Test + @DisplayName("[happy] 지역 기반 페이징 조회 성공") + void loadEstatesByRegionWithPaging_happy() { + // Given + Estate stub = EstateFixtures.stub(); + Pageable pageable = PageRequest.of(0, 10); + + // When + Page result = sut.loadEstatesByRegion(stub.getKaptAddr(), pageable); + + // Then + Assertions.assertThat(result.getContent()).isNotEmpty(); + Assertions.assertThat(result.getContent().get(0).getKaptAddr()) + .contains(stub.getKaptAddr()); + } + + @Test + @DisplayName("[happy] 지역 기반 전체 조회 성공") + void loadEstatesByRegionWithoutPaging_happy() { + // Given + Estate stub = EstateFixtures.stub(); + + // When + List result = sut.loadEstatesByRegion(stub.getKaptAddr()); + + // Then + Assertions.assertThat(result).isNotEmpty(); + Assertions.assertThat(result.get(0).getKaptAddr()) + .contains(stub.getKaptAddr()); + } + + @Test + @DisplayName("[happy] 이름 기반 조회 성공") + void loadByName_happy() { + // Given + Estate stub = EstateFixtures.stub(); + + // When + Optional result = sut.loadEstateByName(stub.getKaptName()); + + // Then + Assertions.assertThat(result).isPresent(); + Assertions.assertThat(result.get().getKaptName()) + .isEqualTo(stub.getKaptName()); + } + + + @Test + @DisplayName("[happy] 지정 반경 내 근처 아파트 조회 성공") + void loadEstatesNearBy_happy() { + // Given + Estate stub = EstateFixtures.stub(); + double lat = stub.getLocation().getLatitude(); + double lon = stub.getLocation().getLongitude(); + double radius = 1.0; // 1km 반경 + + // When + List result = sut.loadEstatesNearBy(lon, lat, radius); + + // Then + Assertions.assertThat(result).isNotEmpty(); + Assertions.assertThat(result.get(0).getKaptCode()) + .isEqualTo(stub.getKaptCode()); + } + } + + @Nested + @DisplayName("체크 테스트") + class Check { + + @Test + @DisplayName("[happy] 코드에 해당하는 값이 있는 경우 true 반환") + void exists_happy() { + // Given + Estate stub = EstateFixtures.stub(); + String kaptCode = stub.getKaptCode(); + + // When + boolean exists = sut.checkExistingByCode(kaptCode); + + // Then + Assertions.assertThat(exists).isTrue(); + } + + @Test + @DisplayName("[happy] 코드에 해당하는 값이 없는 경우 false 반환") + void not_exists_happy() { + // Given + String nonExistCode = "NON_EXIST_KAPT_CODE"; + + // When + boolean exists = sut.checkExistingByCode(nonExistCode); + + // Then + Assertions.assertThat(exists).isFalse(); + } + + @Test + @DisplayName("[edge] 존재하지 않는 KAPT 코드로 false 반환") + void checkExistingByCode_false() { + // Given + String fakeCode = "FAKE-CODE-0000"; + + // When + boolean exists = sut.checkExistingByCode(fakeCode); + + // Then + Assertions.assertThat(exists).isFalse(); + } + + + } + + +} diff --git a/src/test/java/com/zipte/platform/server/domain/estate/EstateFixtures.java b/src/test/java/com/zipte/platform/server/domain/estate/EstateFixtures.java new file mode 100644 index 0000000..4d28e80 --- /dev/null +++ b/src/test/java/com/zipte/platform/server/domain/estate/EstateFixtures.java @@ -0,0 +1,40 @@ +package com.zipte.platform.server.domain.estate; + +import java.util.Arrays; + +public class EstateFixtures { + + public static Estate stub(){ + + Location location = Location.builder() + .coordinates(Arrays.asList( + 127.123456, + 37.12345 + )).build(); + + return Estate.builder() + .id("test-estate-id") + .kaptCode("TEST-KAPT-CODE-1234") + .pricePerSquareMeter("test-price-1500000") + .kaptAddr("test-경기도 성남시 분당구 테스트로 123") + .kaptMparea_135("test-area-135") + .kaptMparea_136("test-area-136") + .kaptMparea_60("test-area-60") + .kaptMparea_85("test-area-85") + .kaptName("테스트힐스테이트아파트") + .location(location) // test 위도/경도 + .convenientFacility("test-편의시설1, test-편의시설2") + .educationFacility("test-초등학교, test-중학교") + .kaptdPcnt("test-85.0") + .kaptdPcntu("test-82.3") + .kaptdWtimebus("test-버스 15분") + .kaptdWtimesub("test-지하철 10분") + .subwayLine("test-2호선") + .subwayStation("test-강남역") + .welfareFacility("test-주민센터, test-복지관") + .build(); + + + } + +}