diff --git a/build.gradle b/build.gradle index 607cada3e..5cf12e1c9 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ repositories { } dependencies { + implementation group: 'org.json', name: 'json', version: '20231013' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java b/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java index 89da881c9..4cada24d9 100644 --- a/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java +++ b/src/main/java/in/koreatech/koin/domain/land/controller/LandController.java @@ -4,9 +4,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.land.dto.LandListItemResponse; +import in.koreatech.koin.domain.land.dto.LandResponse; import in.koreatech.koin.domain.land.service.LandService; import lombok.RequiredArgsConstructor; @@ -21,4 +23,10 @@ public ResponseEntity> getLands() { List responses = landService.getLands(); return ResponseEntity.ok(responses); } + + @GetMapping("/lands/{id}") + public ResponseEntity getLand(@PathVariable Long id) { + LandResponse response = landService.getLand(id); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandListItemResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandListItemResponse.java index 1858d4b26..6bd2e0d65 100644 --- a/src/main/java/in/koreatech/koin/domain/land/dto/LandListItemResponse.java +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandListItemResponse.java @@ -10,11 +10,11 @@ public record LandListItemResponse( String internalName, String monthlyFee, - String latitude, + Double latitude, String charterFee, String name, Long id, - String longitude, + Double longitude, String roomType) { public static LandListItemResponse from(Land land) { diff --git a/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java new file mode 100644 index 000000000..6577dbec5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/dto/LandResponse.java @@ -0,0 +1,92 @@ +package in.koreatech.koin.domain.land.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.land.model.Land; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record LandResponse( + Boolean optElectronicDoorLocks, + Boolean optTv, + String monthlyFee, + Boolean optElevator, + Boolean optWaterPurifier, + Boolean optWasher, + Double latitude, + String charterFee, + Boolean optVeranda, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + String description, + List imageUrls, + Boolean optGasRange, + Boolean optInduction, + String internalName, + Boolean isDeleted, + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt, + Boolean optBidet, + Boolean optShoeCloset, + Boolean optRefrigerator, + Long id, + Long floor, + String managementFee, + Boolean optDesk, + Boolean optCloset, + Double longitude, + String address, + Boolean optBed, + Double size, + String phone, + Boolean optAirConditioner, + String name, + String deposit, + Boolean optMicrowave, + String permalink, + String roomType) { + + public static LandResponse of(Land land, List imageUrls, String permalink) { + return new LandResponse( + land.getOptElectronicDoorLocks(), + land.getOptTv(), + land.getMonthlyFee(), + land.getOptElevator(), + land.getOptWaterPurifier(), + land.getOptWasher(), + land.getLatitude(), + land.getCharterFee(), + land.getOptVeranda(), + land.getCreatedAt(), + land.getDescription(), + imageUrls, + land.getOptGasRange(), + land.getOptInduction(), + land.getInternalName(), + land.getIsDeleted(), + land.getUpdatedAt(), + land.getOptBidet(), + land.getOptShoeCloset(), + land.getOptRefrigerator(), + land.getId(), + land.getFloor(), + land.getManagementFee(), + land.getOptDesk(), + land.getOptCloset(), + land.getLongitude(), + land.getAddress(), + land.getOptBed(), + land.getSize(), + land.getPhone(), + land.getOptAirConditioner(), + land.getName(), + land.getDeposit(), + land.getOptMicrowave(), + permalink, + land.getRoomType() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java b/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java new file mode 100644 index 000000000..e863e046e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/land/exception/LandNotFoundException.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.land.exception; + +public class LandNotFoundException extends RuntimeException { + private static final String DEFAULT_MESSAGE = "복덕방이 존재하지 않습니다."; + + public LandNotFoundException(String message) { + super(message); + } + + public static LandNotFoundException withDetail(String detail) { + String message = String.format("%s %s", DEFAULT_MESSAGE, detail); + return new LandNotFoundException(message); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/land/model/Land.java b/src/main/java/in/koreatech/koin/domain/land/model/Land.java index fa89834c4..667bc82b2 100644 --- a/src/main/java/in/koreatech/koin/domain/land/model/Land.java +++ b/src/main/java/in/koreatech/koin/domain/land/model/Land.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.land.model; +import in.koreatech.koin.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +19,7 @@ @Entity @Table(name = "lands") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Land { +public class Land extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -35,21 +36,18 @@ public class Land { @Column(name = "internal_name", nullable = false, length = 50) private String internalName; - @Size(max = 20) @Column(name = "size", length = 20) - private String size; + private Double size; @Size(max = 20) @Column(name = "room_type", length = 20) private String roomType; - @Size(max = 20) @Column(name = "latitude", length = 20) - private String latitude; + private Double latitude; - @Size(max = 20) @Column(name = "longitude", length = 20) - private String longitude; + private Double longitude; @Size(max = 20) @Column(name = "phone", length = 20) @@ -155,7 +153,7 @@ public class Land { private Boolean isDeleted = false; @Builder - private Land(String internalName, String name, String size, String roomType, String latitude, String longitude, + private Land(String internalName, String name, Double size, String roomType, Double latitude, Double longitude, String phone, String imageUrls, String address, String description, Long floor, String deposit, String monthlyFee, String charterFee, String managementFee) { this.internalName = internalName; diff --git a/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java b/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java index b653d085e..af769c7ee 100644 --- a/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java +++ b/src/main/java/in/koreatech/koin/domain/land/repository/LandRepository.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.land.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; @@ -10,5 +11,7 @@ public interface LandRepository extends Repository { List findAll(); + Optional findById(Long id); + Land save(Land request); } diff --git a/src/main/java/in/koreatech/koin/domain/land/service/LandService.java b/src/main/java/in/koreatech/koin/domain/land/service/LandService.java index 7f6d565de..d9e3af533 100644 --- a/src/main/java/in/koreatech/koin/domain/land/service/LandService.java +++ b/src/main/java/in/koreatech/koin/domain/land/service/LandService.java @@ -1,11 +1,17 @@ package in.koreatech.koin.domain.land.service; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; +import org.json.JSONArray; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.land.dto.LandListItemResponse; +import in.koreatech.koin.domain.land.dto.LandResponse; +import in.koreatech.koin.domain.land.exception.LandNotFoundException; +import in.koreatech.koin.domain.land.model.Land; import in.koreatech.koin.domain.land.repository.LandRepository; import lombok.RequiredArgsConstructor; @@ -22,4 +28,22 @@ public List getLands() { .map(LandListItemResponse::from) .toList(); } + + public LandResponse getLand(Long id) { + Land land = landRepository.findById(id) + .orElseThrow(() -> LandNotFoundException.withDetail("id: " + id)); + + String image = land.getImageUrls(); + List imageUrls = null; + + if (image != null) { + imageUrls = new JSONArray(image) + .toList() + .stream() + .map(Object::toString) + .toList(); + } + + return LandResponse.of(land, imageUrls, URLEncoder.encode(land.getInternalName(), StandardCharsets.UTF_8)); + } } diff --git a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java index 17b4be846..2adf5265d 100644 --- a/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/in/koreatech/koin/global/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import in.koreatech.koin.domain.auth.exception.AuthException; import in.koreatech.koin.domain.community.exception.ArticleNotFoundException; +import in.koreatech.koin.domain.land.exception.LandNotFoundException; import in.koreatech.koin.domain.user.exception.UserNotFoundException; import in.koreatech.koin.global.exception.ErrorResponse.ErrorResponseWrapper; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,13 @@ public ResponseEntity handleAuthException(AuthException e) { .body(ErrorResponse.from("잘못된 인증정보입니다.")); } + @ExceptionHandler + public ResponseEntity handleAuthException(LandNotFoundException e) { + log.warn(e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponseWrapper.from(ErrorResponse.from("복덕방이 존재하지 않습니다."))); + } + @ExceptionHandler public ResponseEntity handleArticleNotFoundException(ArticleNotFoundException e) { log.warn(e.getMessage()); diff --git a/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java b/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java index 466d8155f..c925efde5 100644 --- a/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/LandApiTest.java @@ -1,5 +1,9 @@ package in.koreatech.koin.acceptance; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; + import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,8 +29,8 @@ void getLands() { .internalName("복덕방") .name("복덕방") .roomType("원룸") - .latitude("37.555") - .longitude("126.555") + .latitude(37.555) + .longitude(126.555) .monthlyFee("100") .charterFee("1000") .build(); @@ -53,9 +57,9 @@ void getLands() { softly.assertThat(response.body().jsonPath().getString("[0].name")).isEqualTo(land.getName()); softly.assertThat(response.body().jsonPath().getString("[0].room_type")) .isEqualTo(land.getRoomType()); - softly.assertThat(response.body().jsonPath().getString("[0].latitude")) + softly.assertThat(response.body().jsonPath().getDouble("[0].latitude")) .isEqualTo(land.getLatitude()); - softly.assertThat(response.body().jsonPath().getString("[0].longitude")) + softly.assertThat(response.body().jsonPath().getDouble("[0].longitude")) .isEqualTo(land.getLongitude()); softly.assertThat(response.body().jsonPath().getString("[0].monthly_fee")) .isEqualTo(land.getMonthlyFee()); @@ -64,4 +68,114 @@ void getLands() { } ); } + + @Test + @DisplayName("복덕방을 단일 조회한다.") + void getLand() { + Land request = Land.builder() + .internalName("복덕방") + .name("복덕방") + .roomType("원룸") + .latitude(37.555) + .longitude(126.555) + .floor(1L) + .monthlyFee("100") + .charterFee("1000") + .deposit("1000") + .managementFee("100") + .phone("010-1234-5678") + .address("서울시 강남구") + .size(100.0) + .imageUrls(""" + ["https://example1.test.com/image.jpeg", + "https://example2.test.com/image.jpeg"] + """) + .build(); + + Land land = landRepository.save(request); + + ExtractableResponse response = RestAssured + .given() + .log().all() + .when() + .log().all() + .get("/lands/{id}", land.getId()) + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(response.body().jsonPath().getBoolean("opt_electronic_door_locks")) + .isEqualTo(land.getOptElectronicDoorLocks()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_tv")).isEqualTo(land.getOptTv()); + softly.assertThat(response.body().jsonPath().getString("monthly_fee")) + .isEqualTo(land.getMonthlyFee()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_elevator")) + .isEqualTo(land.getOptElevator()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_water_purifier")) + .isEqualTo(land.getOptWaterPurifier()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_washer")) + .isEqualTo(land.getOptWasher()); + softly.assertThat(response.body().jsonPath().getDouble("latitude")) + .isEqualTo(land.getLatitude()); + softly.assertThat(response.body().jsonPath().getString("charter_fee")) + .isEqualTo(land.getCharterFee()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_veranda")) + .isEqualTo(land.getOptVeranda()); + softly.assertThat(response.body().jsonPath().getString("created_at")) + .isEqualTo(land.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + softly.assertThat(response.body().jsonPath().getString("description")) + .isEqualTo(land.getDescription()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_gas_range")) + .isEqualTo(land.getOptGasRange()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_induction")) + .isEqualTo(land.getOptInduction()); + softly.assertThat(response.body().jsonPath().getString("internal_name")) + .isEqualTo(land.getInternalName()); + softly.assertThat(response.body().jsonPath().getBoolean("is_deleted")) + .isEqualTo(land.getIsDeleted()); + softly.assertThat(response.body().jsonPath().getString("updated_at")) + .isEqualTo(land.getUpdatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + softly.assertThat(response.body().jsonPath().getBoolean("opt_bidet")) + .isEqualTo(land.getOptBidet()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_shoe_closet")) + .isEqualTo(land.getOptShoeCloset()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_refrigerator")) + .isEqualTo(land.getOptRefrigerator()); + softly.assertThat(response.body().jsonPath().getLong("id")).isEqualTo(land.getId()); + softly.assertThat(response.body().jsonPath().getLong("floor")).isEqualTo(land.getFloor()); + softly.assertThat(response.body().jsonPath().getString("management_fee")) + .isEqualTo(land.getManagementFee()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_desk")) + .isEqualTo(land.getOptDesk()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_closet")) + .isEqualTo(land.getOptCloset()); + softly.assertThat(response.body().jsonPath().getDouble("longitude")) + .isEqualTo(land.getLongitude()); + softly.assertThat(response.body().jsonPath().getString("address")) + .isEqualTo(land.getAddress()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_bed")) + .isEqualTo(land.getOptBed()); + softly.assertThat(response.body().jsonPath().getDouble("size")) + .isEqualTo(land.getSize()); + softly.assertThat(response.body().jsonPath().getString("phone")) + .isEqualTo(land.getPhone()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_air_conditioner")) + .isEqualTo(land.getOptAirConditioner()); + softly.assertThat(response.body().jsonPath().getString("name")) + .isEqualTo(land.getName()); + softly.assertThat(response.body().jsonPath().getString("deposit")) + .isEqualTo(land.getDeposit()); + softly.assertThat(response.body().jsonPath().getBoolean("opt_microwave")) + .isEqualTo(land.getOptMicrowave()); + softly.assertThat(response.body().jsonPath().getString("permalink")) + .isEqualTo(URLEncoder.encode(land.getInternalName(), StandardCharsets.UTF_8)); + softly.assertThat(response.body().jsonPath().getString("room_type")) + .isEqualTo(land.getRoomType()); + softly.assertThat(response.body().jsonPath().getList("image_urls")).hasSize(2); + } + ); + } }