diff --git a/.github/workflows/Continuous Delivery.yml b/.github/workflows/Continuous Delivery.yml new file mode 100644 index 0000000..4a0cea0 --- /dev/null +++ b/.github/workflows/Continuous Delivery.yml @@ -0,0 +1,47 @@ +name: Continuous Delivery + +on: + pull_request: + branches: + - 'main' + +jobs: + Delivery: + runs-on: ubuntu-22.04 + + env: + DB_URL: ${{ secrets.DB_URL }} + DB_USER: ${{ secrets.DB_USR }} + DB_PASSWORD: ${{ secrets.DB_PWD }} + + steps: + - name: Set up sources + uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_TOKEN }} + submodules: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: corretto + cache: gradle + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Execute Gradle build + run: ./gradlew build + + - name: Login to docker-hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build Docker Image + run: docker build --tag goldentrash/plantory:latest . + + - name: Push docker image + run: docker push goldentrash/plantory:latest diff --git a/.github/workflows/Deploy.yml b/.github/workflows/Deploy.yml new file mode 100644 index 0000000..52a4e29 --- /dev/null +++ b/.github/workflows/Deploy.yml @@ -0,0 +1,33 @@ +name: Deploy + +on: + pull_request: + types: + - closed + branches: + - 'main' + +jobs: + Deploy: + if: github.event.pull_request.merged == true + runs-on: ubuntu-22.04 + + steps: + # Needed for `google-github-actions` + - uses: 'actions/checkout@v4' + + - name: Login to Google + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_CREDENTIALS }} + + - name: Update docker container + uses: google-github-actions/ssh-compute@v1 + with: + instance_name: plantory + zone: asia-northeast3-a + ssh_private_key: ${{ secrets.GCP_SSH_KEY }} + command: | + docker pull goldentrash/plantory:latest + docker stop plantory + docker run -d --rm -p 8080:8080 --mount type=volume,src=resources,dst=/app/resources --name plantory goldentrash/plantory diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec40d81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 + +FROM amazoncorretto:17-alpine + +WORKDIR /app +COPY /build/libs/plantory-*-SNAPSHOT.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 75ca3ad..5ce7d53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,9 @@ dependencies { // firebase implementation("com.google.firebase:firebase-admin:9.2.0") + + // swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") } tasks.withType { diff --git a/src/main/kotlin/gdsc/plantory/common/config/SwaggerConfig.kt b/src/main/kotlin/gdsc/plantory/common/config/SwaggerConfig.kt new file mode 100644 index 0000000..88aac86 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/common/config/SwaggerConfig.kt @@ -0,0 +1,20 @@ +package gdsc.plantory.common.config + +import gdsc.plantory.common.support.AccessDeviceToken +import gdsc.plantory.common.support.DEVICE_ID_HEADER +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.security.SecurityRequirement +import org.springdoc.core.customizers.OperationCustomizer +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod + +@Component +class Operation : OperationCustomizer { + override fun customize(operation: Operation?, handlerMethod: HandlerMethod?): Operation? { + if (handlerMethod?.methodParameters?.find { it.hasParameterAnnotation(AccessDeviceToken::class.java) } != null) { + operation?.addSecurityItem(SecurityRequirement().addList(DEVICE_ID_HEADER)) + } + + return operation + } +} \ No newline at end of file diff --git a/src/main/kotlin/gdsc/plantory/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/gdsc/plantory/common/exception/CommonExceptionHandler.kt index 0063aad..93de7d0 100644 --- a/src/main/kotlin/gdsc/plantory/common/exception/CommonExceptionHandler.kt +++ b/src/main/kotlin/gdsc/plantory/common/exception/CommonExceptionHandler.kt @@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.multipart.MaxUploadSizeExceededException @RestControllerAdvice class CommonExceptionHandler { @@ -27,4 +28,9 @@ class CommonExceptionHandler { return ResponseEntity.internalServerError().body(ErrorResponse("서버 에러가 발생했습니다. 관리자에게 문의해주세요.")) } + + @ExceptionHandler + fun handleMaxSizeException(ex: MaxUploadSizeExceededException): ResponseEntity { + return ResponseEntity.badRequest().body(ErrorResponse("파일의 최대 사이즈를 확인해주세요.")) + } } diff --git a/src/main/kotlin/gdsc/plantory/common/support/AccessDeviceToken.kt b/src/main/kotlin/gdsc/plantory/common/support/AccessDeviceToken.kt index 723b604..c7c0af4 100644 --- a/src/main/kotlin/gdsc/plantory/common/support/AccessDeviceToken.kt +++ b/src/main/kotlin/gdsc/plantory/common/support/AccessDeviceToken.kt @@ -1,5 +1,9 @@ package gdsc.plantory.common.support +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn + @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) +@Parameter(`in` = ParameterIn.HEADER, name = DEVICE_ID_HEADER, description = "사용자 디바이스/인증 토큰") annotation class AccessDeviceToken diff --git a/src/main/kotlin/gdsc/plantory/common/support/DeviceHeaderExtractor.kt b/src/main/kotlin/gdsc/plantory/common/support/DeviceHeaderExtractor.kt index d4eb24d..9d05913 100644 --- a/src/main/kotlin/gdsc/plantory/common/support/DeviceHeaderExtractor.kt +++ b/src/main/kotlin/gdsc/plantory/common/support/DeviceHeaderExtractor.kt @@ -2,7 +2,7 @@ package gdsc.plantory.common.support import org.springframework.web.context.request.NativeWebRequest -private const val DEVICE_ID_HEADER = "Device-Token" +const val DEVICE_ID_HEADER = "Device-Token" class DeviceHeaderExtractor { diff --git a/src/main/kotlin/gdsc/plantory/common/support/photo/PhotoLocalManager.kt b/src/main/kotlin/gdsc/plantory/common/support/photo/PhotoLocalManager.kt index 854d51c..286b17f 100644 --- a/src/main/kotlin/gdsc/plantory/common/support/photo/PhotoLocalManager.kt +++ b/src/main/kotlin/gdsc/plantory/common/support/photo/PhotoLocalManager.kt @@ -58,7 +58,7 @@ class PhotoLocalManager( private fun uploadFileInLocal(multipartFile: MultipartFile, uploadPath: File) { try { - multipartFile.transferTo(uploadPath) + multipartFile.transferTo(uploadPath.toPath()) } catch (e: IOException) { throw IllegalStateException("파일 변환이 실패했습니다.") } diff --git a/src/main/kotlin/gdsc/plantory/member/presentation/MemberCommandApi.kt b/src/main/kotlin/gdsc/plantory/member/presentation/MemberCommandApi.kt index 93d831f..9cc9331 100644 --- a/src/main/kotlin/gdsc/plantory/member/presentation/MemberCommandApi.kt +++ b/src/main/kotlin/gdsc/plantory/member/presentation/MemberCommandApi.kt @@ -2,21 +2,28 @@ package gdsc.plantory.member.presentation import gdsc.plantory.member.presentation.dto.MemberCreateRequest import gdsc.plantory.member.service.MemberService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Tag(name = "Member Command", description = "사용자 정보 수정") @RestController @RequestMapping("/api/v1/members") class MemberCommandApi( private val memberService: MemberService, ) { + @Operation(summary = "사용자 등록", description = "사용자를 등록/추가합니다.") + @ApiResponse(responseCode = "200", description = "등록 성공") @PostMapping fun signUp( - @RequestBody request: MemberCreateRequest, + @Parameter(description = "사용자 정보") @RequestBody request: MemberCreateRequest, ): ResponseEntity { memberService.signUp(request.deviceToken) return ResponseEntity.ok().build() diff --git a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlant.kt b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlant.kt index 5a0fe2f..371a33e 100644 --- a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlant.kt +++ b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlant.kt @@ -82,6 +82,9 @@ class CompanionPlant( val getNickName: String get() = this.nickname.value + val getName: String + get() = this.nickname.value + val getSortDescription: String get() = this.shortDescription.value @@ -97,12 +100,11 @@ class CompanionPlant( } this.records.add(PlantRecord(imageUrl, comment, this)) + this.saveRecordHistory(date) } fun saveHistory(historyType: HistoryType, date: LocalDate = LocalDate.now()) { - if (isNotCurrentDay(date)) { - throw IllegalArgumentException("물을 줄 날짜는 오늘 날짜여야 합니다.") - } + validateInput(historyType, date) if (historyType == HistoryType.WATER_CHANGE) { this.lastWaterDate = date @@ -122,6 +124,22 @@ class CompanionPlant( return ChronoUnit.DAYS.between(birthDate, currentDate).toInt() + 1 } + private fun validateInput(historyType: HistoryType, date: LocalDate) { + if (isRecordType(historyType)) { + throw IllegalArgumentException("데일리 기록은 히스토리 타입을 직접 추가할 수 없습니다.") + } + + if (isNotCurrentDay(date)) { + throw IllegalArgumentException("물을 줄 날짜는 오늘 날짜여야 합니다.") + } + } + + private fun isRecordType(historyType: HistoryType) = historyType == HistoryType.RECORDING + + private fun saveRecordHistory(date: LocalDate) { + this.histories.add(PlantHistory(HistoryType.RECORDING, date, this)) + } + private fun isNotCurrentDay(date: LocalDate) = !date.isEqual(LocalDate.now()) override fun equals(other: Any?): Boolean { diff --git a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt index 583ec4b..2f9f6bb 100644 --- a/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt +++ b/src/main/kotlin/gdsc/plantory/plant/domain/CompanionPlantRepository.kt @@ -1,7 +1,9 @@ package gdsc.plantory.plant.domain import NotFoundException +import gdsc.plantory.plant.presentation.dto.CompanionPlantLookupDto import gdsc.plantory.plant.presentation.dto.CompanionPlantWaterCycleDto +import gdsc.plantory.plant.presentation.dto.PlantRecordDto import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import java.time.LocalDate @@ -10,16 +12,56 @@ fun CompanionPlantRepository.findByIdAndMemberIdOrThrow(id: Long, memberId: Long return findByIdAndMemberId(id, memberId) ?: throw NotFoundException("식물 정보가 없어요") } -fun CompanionPlantRepository.findRecordByDateOrThrow(id: Long, memberId: Long, date: LocalDate): PlantRecord { - return findRecordByDate(id, memberId, date) ?: throw NotFoundException("데일리 기록이 없어요") -} - interface CompanionPlantRepository : JpaRepository { - fun findAllByMemberId(memberId: Long): List fun findByIdAndMemberId(id: Long, memberId: Long): CompanionPlant? fun removeByIdAndMemberId(id: Long, memberId: Long) + @Query( + """ + SELECT new gdsc.plantory.plant.presentation.dto.PlantRecordDto( + r.id, + r.imageUrl._value, + r.comment.content, + cp.nickname._value + ) + FROM CompanionPlant cp LEFT JOIN PlantRecord r + ON cp.id = r.companionPlant.id + WHERE cp.id = :companionPlantId + AND cp.memberId = :memberId + AND DATE(r.createAt) = :recordDate + """ + ) + fun findRecordByDate(companionPlantId: Long, memberId: Long, recordDate: LocalDate): PlantRecordDto + + @Query( + """ + SELECT history.type + FROM PlantHistory history + WHERE + history.companionPlant.id = :companionPlantId + AND DATE(history.createAt) = :recordDate + """ + ) + fun findAllHistoryTypeByDate(companionPlantId: Long, recordDate: LocalDate): List + + @Query( + """ + SELECT new gdsc.plantory.plant.presentation.dto.CompanionPlantLookupDto( + plant.id, + plant.imageUrl._value, + plant.nickname._value, + plant.shortDescription._value, + plant.birthDate, + information.species.name + ) + FROM PlantInformation information JOIN CompanionPlant plant + ON information.id = plant.plantInformationId + WHERE plant.memberId = :memberId + """ + ) + fun findAllByMemberId(memberId: Long): List + @Query( """ SELECT new gdsc.plantory.plant.presentation.dto.CompanionPlantWaterCycleDto( @@ -44,15 +86,4 @@ interface CompanionPlantRepository : JpaRepository { """ ) fun findAllHistoriesByMonth(id: Long, memberId: Long, year: Int, month: Int): List - - @Query( - """ - SELECT record FROM PlantRecord record - WHERE - record.companionPlant.id = :id - AND record.companionPlant.memberId = :memberId - AND DATE(record.createAt) = :date - """ - ) - fun findRecordByDate(id: Long, memberId: Long, date: LocalDate): PlantRecord? } diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/PlantCommandApi.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/PlantCommandApi.kt index faee114..64f3770 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/PlantCommandApi.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/PlantCommandApi.kt @@ -4,13 +4,17 @@ import BadRequestException import gdsc.plantory.common.support.AccessDeviceToken import gdsc.plantory.plant.domain.HistoryType import gdsc.plantory.plant.presentation.dto.CompanionPlantCreateRequest -import gdsc.plantory.plant.presentation.dto.CompanionPlantDeleteRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import gdsc.plantory.plant.presentation.dto.PlantHistoryRequest +import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import gdsc.plantory.plant.service.PlantService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -18,50 +22,64 @@ import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +@Tag(name = "Plant Command", description = "반려식물 정보 수정") @RestController @RequestMapping("/api/v1/plants") class PlantCommandApi( private val plantService: PlantService, ) { + @Operation(summary = "반려식물 등록", description = "사용자의 반려식물을 등록/추가합니다.") + @ApiResponse(responseCode = "200", description = "등록 성공") @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE]) fun create( - @RequestPart(name = "request") request: CompanionPlantCreateRequest, - @RequestPart(name = "image", required = false) image: MultipartFile?, + @Parameter(description = "반려식물 정보") @RequestPart(name = "request") request: CompanionPlantCreateRequest, + @Parameter(description = "반려식물 사진") @RequestPart(name = "image", required = false) image: MultipartFile?, @AccessDeviceToken deviceToken: String, ): ResponseEntity { plantService.create(request, image, deviceToken) return ResponseEntity.ok().build() } - @DeleteMapping(consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "반려식물 삭제", description = "사용자의 반려식물을 삭제합니다.") + @ApiResponse(responseCode = "204", description = "삭제 성공") + @DeleteMapping("/{companionPlantId}") fun remove( - @RequestBody request: CompanionPlantDeleteRequest, + @Parameter(description = "삭제할 반려식물 ID") @PathVariable companionPlantId: Long, @AccessDeviceToken deviceToken: String, ): ResponseEntity { - plantService.remove(request, deviceToken) + plantService.remove(companionPlantId, deviceToken) return ResponseEntity.noContent().build() } - @PostMapping("/histories", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "반려식물 히스토리 등록", description = "반려식물 히스토리(데일리 기록, 물줌, 분갈이)를 등록/추가합니다.") + @ApiResponse(responseCode = "200", description = "등록 성공") + @PostMapping("/{companionPlantId}/histories", consumes = [MediaType.APPLICATION_JSON_VALUE]) fun createPlantHistory( - @RequestBody request: PlantHistoryRequest, + @Parameter(description = "히스토리를 등록할 반려식물 ID") @PathVariable companionPlantId: Long, + @Parameter(description = "히스토리 유형/종류") @RequestBody request: PlantHistoryRequest, @AccessDeviceToken deviceToken: String, ): ResponseEntity { val historyType = HistoryType.byNameIgnoreCaseOrNull(request.historyType) ?: throw BadRequestException("잘못된 히스토리 타입입니다.") - plantService.createPlantHistory(request.companionPlantId, deviceToken, historyType) + plantService.createPlantHistory(companionPlantId, deviceToken, historyType) return ResponseEntity.ok().build() } - @PostMapping("/records", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "반려식물 데일리 기록 등록", description = "반려식물 데일리 기록을 등록/추가합니다.") + @ApiResponse(responseCode = "200", description = "등록 성공") + @PostMapping( + "/{companionPlantId}/records", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE] + ) fun createPlantRecord( - @RequestPart(name = "request") request: PlantRecordCreateRequest, - @RequestPart(name = "image", required = false) image: MultipartFile?, + @Parameter(description = "데일리 기록을 등록할 반려식물 ID") @PathVariable companionPlantId: Long, + @Parameter(description = "데일리 기록 내용") @RequestPart request: PlantRecordCreateRequest, + @Parameter(description = "데일리 기록 사진") @RequestPart(name = "image", required = false) image: MultipartFile?, @AccessDeviceToken deviceToken: String, ): ResponseEntity { - plantService.createRecord(request, image, deviceToken) + plantService.createRecord(companionPlantId, request, image, deviceToken) return ResponseEntity.ok().build() } } diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/PlantQueryApi.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/PlantQueryApi.kt index 28a07f8..22071d6 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/PlantQueryApi.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/PlantQueryApi.kt @@ -2,24 +2,31 @@ package gdsc.plantory.plant.presentation import gdsc.plantory.common.support.AccessDeviceToken import gdsc.plantory.plant.presentation.dto.CompanionPlantsLookupResponse -import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupRequest import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupResponse -import gdsc.plantory.plant.presentation.dto.PlantRecordDto -import gdsc.plantory.plant.presentation.dto.PlantRecordLookupRequest +import gdsc.plantory.plant.presentation.dto.PlantRecordLookupResponse import gdsc.plantory.plant.service.PlantService -import org.springframework.http.MediaType +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate +import java.time.YearMonth +@Tag(name = "Plant Query", description = "반려식물 정보 조회") @RestController @RequestMapping("/api/v1/plants") class PlantQueryApi( private val plantService: PlantService ) { + @Operation(summary = "반려식물 조회", description = "사용자의 반려식물 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") @GetMapping fun lookupAllCompanionPlantsOfMember( @AccessDeviceToken deviceToken: String @@ -28,21 +35,27 @@ class PlantQueryApi( return ResponseEntity.ok().body(companionPlants) } - @GetMapping("/histories", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "반려식물 히스토리(데일리 기록, 물줌, 분갈이) 조회", description = "해당 달의 반려식물 히스토리를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping("/{companionPlantId}/histories") fun lookupAllPlantHistoriesOfMonth( - @RequestBody request: PlantHistoriesLookupRequest, + @Parameter(description = "조회할 반려식물 ID") @PathVariable companionPlantId: Long, + @Parameter(description = "조회 기간(달)") @RequestParam targetMonth: YearMonth, @AccessDeviceToken deviceToken: String ): ResponseEntity { - val histories = plantService.lookupAllPlantHistoriesOfMonth(request, deviceToken) + val histories = plantService.lookupAllPlantHistoriesOfMonth(companionPlantId, targetMonth, deviceToken) return ResponseEntity.ok().body(histories) } - @GetMapping("/records", consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "반려식물 데일리 기록 조회", description = "데일리 기록(이미지, 일지 등)을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping("/{companionPlantId}/records") fun lookupPlantRecordOfDate( - @RequestBody request: PlantRecordLookupRequest, + @Parameter(description = "데일리 기록의 반려식물") @PathVariable companionPlantId: Long, + @Parameter(description = "데일리 기록의 날짜") @RequestParam recordDate: LocalDate, @AccessDeviceToken deviceToken: String - ): ResponseEntity { - val plantRecord = plantService.lookupPlantRecordOfDate(request, deviceToken) + ): ResponseEntity { + val plantRecord = plantService.lookupPlantRecordOfDate(companionPlantId, recordDate, deviceToken) return ResponseEntity.ok().body(plantRecord) } } diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantCreateRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantCreateRequest.kt index c4995f1..bfff546 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantCreateRequest.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantCreateRequest.kt @@ -17,9 +17,11 @@ data class CompanionPlantCreateRequest( @PastOrPresent(message = "마지막 물주기 날짜는 과거 또는 현재의 날짜여야 합니다. lastWaterDate: \${validatedValue}") @DateTimeFormat(pattern = "yyyy-MM-dd") val lastWaterDate: LocalDate, ) { - fun toEntity(imagePath: String, memberId: Long, waterCycle: Int): CompanionPlant { + fun toEntity(imagePath: String, memberId: Long, waterCycle: Int, plantInformationId: Long): CompanionPlant { + // TODO : Cloud 환경으로 이전 후 제거, 로컬 사진 저장 테스트 용도 + val baseUrl = "https://nongsaro.go.kr/" return CompanionPlant( - _imageUrl = imagePath, + _imageUrl = baseUrl + imagePath, _shortDescription = this.shortDescription, _nickname = this.nickname, nextWaterDate = this.lastWaterDate.plusDays(waterCycle.toLong()), @@ -27,6 +29,7 @@ data class CompanionPlantCreateRequest( waterCycle = waterCycle, birthDate = this.birthDate, memberId = memberId, + plantInformationId = plantInformationId ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDeleteRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDeleteRequest.kt deleted file mode 100644 index 4f37761..0000000 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDeleteRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package gdsc.plantory.plant.presentation.dto - -data class CompanionPlantDeleteRequest( - val companionPlantId: Long -) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDto.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDto.kt deleted file mode 100644 index dcdd033..0000000 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantDto.kt +++ /dev/null @@ -1,22 +0,0 @@ -package gdsc.plantory.plant.presentation.dto - -import gdsc.plantory.plant.domain.CompanionPlant -import java.time.LocalDate - -data class CompanionPlantDto( - val id: Long, - val imageUrl: String, - val nickname: String, - val shortDescription: String, - val birthDate: LocalDate, -) { - companion object { - fun from(plant: CompanionPlant): CompanionPlantDto = CompanionPlantDto( - id = plant.getId, - imageUrl = plant.getImageUrl, - nickname = plant.getNickName, - shortDescription = plant.getSortDescription, - birthDate = plant.getBirthDate - ) - } -} diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantLookupDto.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantLookupDto.kt new file mode 100644 index 0000000..bb55d27 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantLookupDto.kt @@ -0,0 +1,12 @@ +package gdsc.plantory.plant.presentation.dto + +import java.time.LocalDate + +class CompanionPlantLookupDto( + val id: Long, + val imageUrl: String, + val nickname: String, + val shortDescription: String, + val birthDate: LocalDate, + val name: String, +) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantsLookupResponse.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantsLookupResponse.kt index e3d6438..832fae3 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantsLookupResponse.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/CompanionPlantsLookupResponse.kt @@ -1,17 +1,5 @@ package gdsc.plantory.plant.presentation.dto -import gdsc.plantory.plant.domain.CompanionPlant - data class CompanionPlantsLookupResponse( - val companionPlants: List -) { - companion object { - fun from(companionPlants: List): CompanionPlantsLookupResponse = - CompanionPlantsLookupResponse( - companionPlants - .stream() - .map { CompanionPlantDto.from(it) } - .toList() - ) - } -} + val companionPlants: List +) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoriesLookupRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoriesLookupRequest.kt deleted file mode 100644 index fa67bc4..0000000 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoriesLookupRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gdsc.plantory.plant.presentation.dto - -import java.time.YearMonth - -data class PlantHistoriesLookupRequest( - val companionPlantId: Long, - val targetMonth: YearMonth -) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoryRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoryRequest.kt index 5eed80b..df64014 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoryRequest.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantHistoryRequest.kt @@ -3,6 +3,5 @@ package gdsc.plantory.plant.presentation.dto import jakarta.validation.constraints.NotBlank data class PlantHistoryRequest( - @NotBlank val companionPlantId: Long, @NotBlank val historyType: String, ) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordCreateRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordCreateRequest.kt index 5a181f2..c88f420 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordCreateRequest.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordCreateRequest.kt @@ -1,6 +1,5 @@ package gdsc.plantory.plant.presentation.dto data class PlantRecordCreateRequest( - val companionPlantId: Long, val comment: String, ) \ No newline at end of file diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordDto.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordDto.kt index 97430dc..a1ebe02 100644 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordDto.kt +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordDto.kt @@ -1,17 +1,8 @@ package gdsc.plantory.plant.presentation.dto -import gdsc.plantory.plant.domain.PlantRecord - data class PlantRecordDto( - val id: Long, + val plantRecordId: Long, val imageUrl: String, val comment: String, -) { - companion object { - fun from(plant: PlantRecord): PlantRecordDto = PlantRecordDto( - id = plant.getId, - imageUrl = plant.getImageUrl, - comment = plant.getComment, - ) - } -} + val nickname: String, +) diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupRequest.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupRequest.kt deleted file mode 100644 index 0e63d87..0000000 --- a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gdsc.plantory.plant.presentation.dto - -import java.time.LocalDate - -data class PlantRecordLookupRequest( - val companionPlantId: Long, - val recordDate: LocalDate -) \ No newline at end of file diff --git a/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupResponse.kt b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupResponse.kt new file mode 100644 index 0000000..da0830a --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plant/presentation/dto/PlantRecordLookupResponse.kt @@ -0,0 +1,21 @@ +package gdsc.plantory.plant.presentation.dto + +class PlantRecordLookupResponse( + val plantRecordId: Long, + val imageUrl: String, + val comment: String, + val nickname: String, + val water: Boolean, +) { + companion object { + fun of(plantRecord: PlantRecordDto, hasWater: Boolean): PlantRecordLookupResponse { + return PlantRecordLookupResponse( + plantRecord.plantRecordId, + plantRecord.imageUrl, + plantRecord.comment, + plantRecord.nickname, + hasWater + ) + } + } +} diff --git a/src/main/kotlin/gdsc/plantory/plant/service/PlantService.kt b/src/main/kotlin/gdsc/plantory/plant/service/PlantService.kt index dc77c4e..2c84d8b 100644 --- a/src/main/kotlin/gdsc/plantory/plant/service/PlantService.kt +++ b/src/main/kotlin/gdsc/plantory/plant/service/PlantService.kt @@ -6,20 +6,18 @@ import gdsc.plantory.member.domain.findByDeviceTokenOrThrow import gdsc.plantory.plant.domain.CompanionPlantRepository import gdsc.plantory.plant.domain.HistoryType import gdsc.plantory.plant.domain.findByIdAndMemberIdOrThrow -import gdsc.plantory.plant.domain.findRecordByDateOrThrow -import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupRequest import gdsc.plantory.plant.presentation.dto.CompanionPlantCreateRequest -import gdsc.plantory.plant.presentation.dto.CompanionPlantDeleteRequest import gdsc.plantory.plant.presentation.dto.CompanionPlantsLookupResponse -import gdsc.plantory.plant.presentation.dto.PlantRecordLookupRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordDto -import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupResponse +import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest +import gdsc.plantory.plant.presentation.dto.PlantRecordLookupResponse import gdsc.plantory.plantInformation.domain.PlantInformationRepository import gdsc.plantory.plantInformation.domain.findByIdOrThrow import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile +import java.time.LocalDate +import java.time.YearMonth @Service @Transactional @@ -35,14 +33,19 @@ class PlantService( val findPlantInformation = plantInformationRepository.findByIdOrThrow(request.plantInformationId) val imagePath: String = saveImageAndGetPath(image, findPlantInformation.getImageUrl) - val companionPlant = request.toEntity(imagePath, findMember.getId, findPlantInformation.getWaterCycle) + val companionPlant = request.toEntity( + imagePath, + findMember.getId, + findPlantInformation.getWaterCycle, + request.plantInformationId + ) companionPlantRepository.save(companionPlant) } - fun remove(request: CompanionPlantDeleteRequest, deviceToken: String) { + fun remove(companionPlantId: Long, deviceToken: String) { val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) - companionPlantRepository.removeByIdAndMemberId(request.companionPlantId, findMember.getId) + companionPlantRepository.removeByIdAndMemberId(companionPlantId, findMember.getId) } @Transactional(readOnly = true) @@ -50,56 +53,65 @@ class PlantService( val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) val findCompanionPlants = companionPlantRepository.findAllByMemberId(findMember.getId) - return CompanionPlantsLookupResponse.from(findCompanionPlants) + return CompanionPlantsLookupResponse(findCompanionPlants) } fun createPlantHistory(plantId: Long, deviceToken: String, historyType: HistoryType) { val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) val findCompanionPlant = companionPlantRepository.findByIdAndMemberIdOrThrow(plantId, findMember.getId) - findCompanionPlant.saveHistory(historyType) } @Transactional(readOnly = true) fun lookupAllPlantHistoriesOfMonth( - request: PlantHistoriesLookupRequest, + companionPlantId: Long, + targetMonth: YearMonth, deviceToken: String ): PlantHistoriesLookupResponse { val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) val findPlantHistories = companionPlantRepository.findAllHistoriesByMonth( - request.companionPlantId, + companionPlantId, findMember.getId, - request.targetMonth.year, - request.targetMonth.monthValue + targetMonth.year, + targetMonth.monthValue ) return PlantHistoriesLookupResponse.from(findPlantHistories) } fun createRecord( + companionPlantId: Long, request: PlantRecordCreateRequest, image: MultipartFile?, deviceToken: String, ) { val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) val findCompanionPlant = - companionPlantRepository.findByIdAndMemberIdOrThrow(request.companionPlantId, findMember.getId) + companionPlantRepository.findByIdAndMemberIdOrThrow(companionPlantId, findMember.getId) val imagePath: String = saveImageAndGetPath(image, findCompanionPlant.getImageUrl) - findCompanionPlant.saveRecord(request.comment, imagePath) - findCompanionPlant.saveHistory(HistoryType.RECORDING) + // TODO : Cloud 환경으로 이전 후 제거, 로컬 사진 저장 테스트 용도 + val baseUrl = "https://nongsaro.go.kr/" + findCompanionPlant.saveRecord(request.comment, baseUrl + imagePath) } @Transactional(readOnly = true) - fun lookupPlantRecordOfDate(request: PlantRecordLookupRequest, deviceToken: String): PlantRecordDto { + fun lookupPlantRecordOfDate( + companionPlantId: Long, + recordDate: LocalDate, + deviceToken: String + ): PlantRecordLookupResponse { val findMember = memberRepository.findByDeviceTokenOrThrow(deviceToken) - val findPlantRecord = companionPlantRepository.findRecordByDateOrThrow( - request.companionPlantId, + val findPlantRecord = companionPlantRepository.findRecordByDate( + companionPlantId, findMember.getId, - request.recordDate + recordDate ) - return PlantRecordDto.from(findPlantRecord) + val historyType = + companionPlantRepository.findAllHistoryTypeByDate(companionPlantId, recordDate) + + return PlantRecordLookupResponse.of(findPlantRecord, historyType.contains(HistoryType.WATER_CHANGE)) } private fun saveImageAndGetPath(image: MultipartFile?, defaultUrl: String): String { diff --git a/src/main/kotlin/gdsc/plantory/plantInformation/domain/PlantInformationRepository.kt b/src/main/kotlin/gdsc/plantory/plantInformation/domain/PlantInformationRepository.kt index 83a7d94..7bb4729 100644 --- a/src/main/kotlin/gdsc/plantory/plantInformation/domain/PlantInformationRepository.kt +++ b/src/main/kotlin/gdsc/plantory/plantInformation/domain/PlantInformationRepository.kt @@ -1,7 +1,9 @@ package gdsc.plantory.plantInformation.domain import NotFoundException +import gdsc.plantory.plantInformation.presentation.dto.PlantInformationDto import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import kotlin.jvm.optionals.getOrNull fun PlantInformationRepository.findByIdOrThrow(id: Long): PlantInformation { @@ -9,4 +11,15 @@ fun PlantInformationRepository.findByIdOrThrow(id: Long): PlantInformation { } interface PlantInformationRepository : JpaRepository { + @Query( + """ + SELECT new gdsc.plantory.plantInformation.presentation.dto.PlantInformationDto( + pi.id, + pi.species.name, + pi.species.familyName + ) + FROM PlantInformation pi + """ + ) + fun findAllSpeciesInformations(): List } diff --git a/src/main/kotlin/gdsc/plantory/plantInformation/presentation/PlantInformationQueryApi.kt b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/PlantInformationQueryApi.kt new file mode 100644 index 0000000..440af38 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/PlantInformationQueryApi.kt @@ -0,0 +1,26 @@ +package gdsc.plantory.plantInformation.presentation + +import gdsc.plantory.plantInformation.presentation.dto.PlantInformationsLookupResponse +import gdsc.plantory.plantInformation.service.PlantInformationService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Plant Information Query", description = "식물 정보 조회") +@RestController +@RequestMapping("/api/v1/plantInformations") +class PlantInformationQueryApi( + private val plantInformationService: PlantInformationService +) { + @Operation(summary = "식물 정보 조회", description = "등록된 모든 식물 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping + fun lookupAllPlantInformations(): ResponseEntity { + val plantInformations = plantInformationService.lookupAllPlantInformations() + return ResponseEntity.ok().body(plantInformations) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationDto.kt b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationDto.kt new file mode 100644 index 0000000..0bf6858 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationDto.kt @@ -0,0 +1,7 @@ +package gdsc.plantory.plantInformation.presentation.dto + +data class PlantInformationDto( + val id: Long, + val species: String, + val familyName: String +) \ No newline at end of file diff --git a/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationsLookupResponse.kt b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationsLookupResponse.kt new file mode 100644 index 0000000..659d5fc --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plantInformation/presentation/dto/PlantInformationsLookupResponse.kt @@ -0,0 +1,5 @@ +package gdsc.plantory.plantInformation.presentation.dto + +data class PlantInformationsLookupResponse( + val plantInformations: List +) diff --git a/src/main/kotlin/gdsc/plantory/plantInformation/service/PlantInformationService.kt b/src/main/kotlin/gdsc/plantory/plantInformation/service/PlantInformationService.kt new file mode 100644 index 0000000..b4e3753 --- /dev/null +++ b/src/main/kotlin/gdsc/plantory/plantInformation/service/PlantInformationService.kt @@ -0,0 +1,19 @@ +package gdsc.plantory.plantInformation.service + +import gdsc.plantory.plantInformation.domain.PlantInformationRepository +import gdsc.plantory.plantInformation.presentation.dto.PlantInformationsLookupResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class PlantInformationService( + private val plantInformationRepository: PlantInformationRepository, +) { + @Transactional(readOnly = true) + fun lookupAllPlantInformations(): PlantInformationsLookupResponse { + val findPlantInformations = plantInformationRepository.findAllSpeciesInformations() + + return PlantInformationsLookupResponse(findPlantInformations) + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4dfb374..1a6e4f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: database: mysql database-platform: org.hibernate.dialect.MySQLDialect hibernate: - ddl-auto: create + ddl-auto: validate properties: hibernate: format_sql: true @@ -15,10 +15,14 @@ spring: username: ${DB_USER} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + servlet: + multipart: + max-file-size: 20MB + max-request-size: 25MB local: image: - root: src/test/resources + root: resources companionPlant: image: @@ -26,5 +30,5 @@ companionPlant: fcm: key: - path: src/main/resources/config/google-services.json + path: config/google-services.json scope: prod diff --git a/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantAcceptanceTest.kt b/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantAcceptanceTest.kt index e47071a..82b080a 100644 --- a/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantAcceptanceTest.kt +++ b/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantAcceptanceTest.kt @@ -16,11 +16,8 @@ import gdsc.plantory.fixture.기록있는_테스트식물_ID import gdsc.plantory.fixture.테스터_디바이스_토큰 import gdsc.plantory.fixture.테스트_식물정보_ID import gdsc.plantory.fixture.CompanionPlantFixture.generateCompanionPlantCreateRequest -import gdsc.plantory.fixture.CompanionPlantFixture.generatePlantRecordCreateRequest -import gdsc.plantory.plant.presentation.dto.CompanionPlantDeleteRequest import gdsc.plantory.plant.presentation.dto.PlantHistoryRequest -import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordLookupRequest +import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import gdsc.plantory.util.AcceptanceTest import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -45,11 +42,8 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { @Test fun `반려식물 삭제`() { - // given - val 반려_식물_정보 = CompanionPlantDeleteRequest(기록있는_테스트식물_ID) - // when - val 식물_삭제_요청_응답 = 반려_식물_삭제_요청(반려_식물_정보, 테스터_디바이스_토큰) + val 식물_삭제_요청_응답 = 반려_식물_삭제_요청(기록있는_테스트식물_ID, 테스터_디바이스_토큰) // then 응답_확인(식물_삭제_요청_응답, HttpStatus.NO_CONTENT) @@ -58,10 +52,10 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { @Test fun `반려식물 물주기 히스토리 등록`() { // given - val 물줌_기록 = PlantHistoryRequest(기록없는_테스트식물_ID, "WATER_CHANGE") + val 물줌_기록 = PlantHistoryRequest("WATER_CHANGE") // when - val 식물_히스토리_생성_응답 = 식물_히스토리_생성_요청(물줌_기록, 테스터_디바이스_토큰) + val 식물_히스토리_생성_응답 = 식물_히스토리_생성_요청(기록없는_테스트식물_ID, 물줌_기록, 테스터_디바이스_토큰) // then 응답_확인(식물_히스토리_생성_응답, HttpStatus.OK) @@ -79,10 +73,10 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { @Test fun `반려식물 데일리 기록 등록`() { // given - val 데일리_기록_정보 = generatePlantRecordCreateRequest(기록없는_테스트식물_ID) + val 데일리_기록_정보 = PlantRecordCreateRequest("오늘도 즐거운 하루~!") // when - val 데일리_기록_등록_요청_응답 = 데일리_기록_등록_요청(데일리_기록_정보, 테스터_디바이스_토큰) + val 데일리_기록_등록_요청_응답 = 데일리_기록_등록_요청(기록없는_테스트식물_ID, 데일리_기록_정보, 테스터_디바이스_토큰) // then 응답_확인(데일리_기록_등록_요청_응답, HttpStatus.OK) @@ -97,13 +91,13 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { fun `반려식물 데일리 기록 중복 등록`() { // given 데일리_기록_등록_요청( - generatePlantRecordCreateRequest(기록없는_테스트식물_ID), 테스터_디바이스_토큰 + 기록없는_테스트식물_ID, PlantRecordCreateRequest("오늘도 즐거운 하루~!"), 테스터_디바이스_토큰 ) // when val 데일리_기록_등록_요청_응답 = 데일리_기록_등록_요청( - generatePlantRecordCreateRequest(기록없는_테스트식물_ID), 테스터_디바이스_토큰 + 기록없는_테스트식물_ID, PlantRecordCreateRequest("오늘도 즐거운 하루~!"), 테스터_디바이스_토큰 ) // then @@ -112,11 +106,8 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { @Test fun `반려식물 데일리 기록 조회`() { - // given - val 데일리_기록_조회_정보 = PlantRecordLookupRequest(기록있는_테스트식물_ID, LocalDate.now()) - // when - val 데일리_기록_조회_요청_응답 = 데일리_기록_조회_요청(데일리_기록_조회_정보, 테스터_디바이스_토큰) + val 데일리_기록_조회_요청_응답 = 데일리_기록_조회_요청(기록있는_테스트식물_ID, LocalDate.now(), 테스터_디바이스_토큰) // then 데일리_기록_조회_응답_확인(데일리_기록_조회_요청_응답) @@ -124,11 +115,8 @@ class CompanionPlantAcceptanceTest : AcceptanceTest() { @Test fun `반려식물 히스토리 조회`() { - // given - val 히스토리_조회_정보 = PlantHistoriesLookupRequest(기록있는_테스트식물_ID, YearMonth.parse("2024-01")) - // when - val 히스토리_조회_요청_응답 = 히스토리_조회_요청(히스토리_조회_정보, 테스터_디바이스_토큰) + val 히스토리_조회_요청_응답 = 히스토리_조회_요청(기록있는_테스트식물_ID, YearMonth.parse("2024-01"), 테스터_디바이스_토큰) // then 히스토리_조회_응답_확인(히스토리_조회_요청_응답) diff --git a/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantStep.kt b/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantStep.kt index ef0908a..1736209 100644 --- a/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantStep.kt +++ b/src/test/kotlin/gdsc/plantory/acceptance/CompanionPlantStep.kt @@ -1,11 +1,8 @@ package gdsc.plantory.acceptance import gdsc.plantory.plant.presentation.dto.CompanionPlantCreateRequest -import gdsc.plantory.plant.presentation.dto.CompanionPlantDeleteRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import gdsc.plantory.plant.presentation.dto.PlantHistoryRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordLookupRequest -import gdsc.plantory.plant.presentation.dto.PlantHistoriesLookupRequest +import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import io.restassured.RestAssured import io.restassured.builder.MultiPartSpecBuilder import io.restassured.mapper.ObjectMapperType @@ -16,6 +13,8 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.assertAll import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import java.time.LocalDate +import java.time.YearMonth class CompanionPlantStep { @@ -41,17 +40,15 @@ class CompanionPlantStep { } fun 반려_식물_삭제_요청( - request: CompanionPlantDeleteRequest, + companionPlantId: Long, deviceToken: String, ): ExtractableResponse { return RestAssured .given() - .contentType(MediaType.APPLICATION_JSON_VALUE) .header("Device-Token", deviceToken) .log().all() - .body(request) .`when`() - .delete("/api/v1/plants") + .delete("/api/v1/plants/{companionPlantId}", companionPlantId) .then() .log().all() .statusCode(HttpStatus.NO_CONTENT.value()) @@ -59,6 +56,7 @@ class CompanionPlantStep { } fun 식물_히스토리_생성_요청( + companionPlantId: Long, request: PlantHistoryRequest, deviceToken: String, ): ExtractableResponse = @@ -68,7 +66,7 @@ class CompanionPlantStep { .header("Device-Token", deviceToken) .log().all() .body(request) - .`when`().post("/api/v1/plants/histories") + .`when`().post("/api/v1/plants/{companionPlantId}/histories", companionPlantId) .then() .log().all() .extract() @@ -76,13 +74,14 @@ class CompanionPlantStep { fun 식물_조회_응답_확인(response: ExtractableResponse) { assertAll( { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) }, - { assertThat(response.jsonPath().getString("companionPlants.id")).isNotBlank() }, - { assertThat(response.jsonPath().getString("companionPlants.imageUrl")).isNotBlank() }, - { assertThat(response.jsonPath().getString("companionPlants.nickname")).isNotBlank() }, + { assertThat(response.jsonPath().getString("companionPlants[].id")).isNotBlank() }, + { assertThat(response.jsonPath().getString("companionPlants[].imageUrl")).isNotBlank() }, + { assertThat(response.jsonPath().getString("companionPlants[].nickname")).isNotBlank() }, { - assertThat(response.jsonPath().getString("companionPlants.shortDescription")).isNotBlank() + assertThat(response.jsonPath().getString("companionPlants[].shortDescription")).isNotBlank() }, - { assertThat(response.jsonPath().getString("companionPlants.birthDate")).isNotBlank() }, + { assertThat(response.jsonPath().getString("companionPlants[].birthDate")).isNotBlank() }, + { assertThat(response.jsonPath().getString("companionPlants[].name")).isNotBlank() }, ) } @@ -101,6 +100,7 @@ class CompanionPlantStep { } fun 데일리_기록_등록_요청( + companionPlantId: Long, request: PlantRecordCreateRequest, deviceToken: String, ): ExtractableResponse { @@ -113,24 +113,24 @@ class CompanionPlantStep { .header("Device-Token", deviceToken) .log().all() .`when`() - .post("/api/v1/plants/records") + .post("/api/v1/plants/{companionPlantId}/records", companionPlantId) .then() .log().all() .extract() } fun 데일리_기록_조회_요청( - request: PlantRecordLookupRequest, + companionPlantId: Long, + recordDate: LocalDate, deviceToken: String ): ExtractableResponse { return RestAssured .given() - .contentType(MediaType.APPLICATION_JSON_VALUE) .header("Device-Token", deviceToken) .log().all() - .body(request) + .queryParam("recordDate", recordDate.toString()) .`when`() - .get("/api/v1/plants/records") + .get("/api/v1/plants/{companionPlantId}/records", companionPlantId) .then() .log().all() .extract() @@ -139,24 +139,26 @@ class CompanionPlantStep { fun 데일리_기록_조회_응답_확인(response: ExtractableResponse) { assertAll( { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) }, - { assertThat(response.jsonPath().getString("id")).isNotBlank() }, + { assertThat(response.jsonPath().getString("plantRecordId")).isNotBlank() }, { assertThat(response.jsonPath().getString("imageUrl")).isNotBlank() }, { assertThat(response.jsonPath().getString("comment")).isNotBlank() }, + { assertThat(response.jsonPath().getString("nickname")).isNotBlank() }, + { assertThat(response.jsonPath().getString("water")).isNotBlank() }, ) } fun 히스토리_조회_요청( - request: PlantHistoriesLookupRequest, + companionPlantId: Long, + targetMonth: YearMonth, deviceToken: String ): ExtractableResponse { return RestAssured .given() - .contentType(MediaType.APPLICATION_JSON_VALUE) .header("Device-Token", deviceToken) .log().all() - .body(request) + .queryParam("targetMonth", targetMonth.toString()) .`when`() - .get("/api/v1/plants/histories") + .get("/api/v1/plants/{companionPlantId}/histories", companionPlantId) .then() .log().all() .extract() diff --git a/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationAcceptanceTest.kt b/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationAcceptanceTest.kt new file mode 100644 index 0000000..10f1c42 --- /dev/null +++ b/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationAcceptanceTest.kt @@ -0,0 +1,20 @@ +package gdsc.plantory.acceptance + +import gdsc.plantory.acceptance.PlantInformationStep.Companion.식물_정보_조회_요청 +import gdsc.plantory.acceptance.PlantInformationStep.Companion.식물_정보_조회_응답_확인 +import gdsc.plantory.util.AcceptanceTest +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("인수 : PlantInformation") +class PlantInformationAcceptanceTest : AcceptanceTest() { + + @Test + fun `식물 정보 조회`() { + // when + val 식물_정보_조회_요청_응답 = 식물_정보_조회_요청() + + // then + 식물_정보_조회_응답_확인(식물_정보_조회_요청_응답) + } +} diff --git a/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationStep.kt b/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationStep.kt new file mode 100644 index 0000000..9d21d89 --- /dev/null +++ b/src/test/kotlin/gdsc/plantory/acceptance/PlantInformationStep.kt @@ -0,0 +1,32 @@ +package gdsc.plantory.acceptance + +import io.restassured.RestAssured +import io.restassured.response.ExtractableResponse +import io.restassured.response.Response +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.assertAll +import org.springframework.http.HttpStatus + +class PlantInformationStep { + companion object { + fun 식물_정보_조회_요청(): ExtractableResponse { + return RestAssured + .given() + .log().all() + .`when`() + .get("/api/v1/plantInformations") + .then() + .log().all() + .extract() + } + + fun 식물_정보_조회_응답_확인(response: ExtractableResponse) { + assertAll( + { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) }, + { assertThat(response.jsonPath().getString("plantInformations[].id")).isNotBlank() }, + { assertThat(response.jsonPath().getString("plantInformations[].species")).isNotBlank() }, + { assertThat(response.jsonPath().getString("plantInformations[].familyName")).isNotBlank() }, + ) + } + } +} diff --git a/src/test/kotlin/gdsc/plantory/fixture/CompanionPlantFixture.kt b/src/test/kotlin/gdsc/plantory/fixture/CompanionPlantFixture.kt index 105d65c..fbfc034 100644 --- a/src/test/kotlin/gdsc/plantory/fixture/CompanionPlantFixture.kt +++ b/src/test/kotlin/gdsc/plantory/fixture/CompanionPlantFixture.kt @@ -2,7 +2,6 @@ package gdsc.plantory.fixture import gdsc.plantory.plant.domain.CompanionPlant import gdsc.plantory.plant.presentation.dto.CompanionPlantCreateRequest -import gdsc.plantory.plant.presentation.dto.PlantRecordCreateRequest import java.time.LocalDate private var _기록없는_테스트식물_ID = 0L @@ -71,11 +70,4 @@ object CompanionPlantFixture { lastWaterDate = LocalDate.of(2024, 3, 5), ) } - - fun generatePlantRecordCreateRequest(companionPlantId: Long): PlantRecordCreateRequest { - return PlantRecordCreateRequest( - companionPlantId = companionPlantId, - comment = "오늘도 즐거운 하루~!" - ) - } } diff --git a/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantTest.kt b/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantTest.kt index aad3c4d..ff97ee4 100644 --- a/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantTest.kt +++ b/src/test/kotlin/gdsc/plantory/plant/domain/CompanionPlantTest.kt @@ -30,6 +30,23 @@ class CompanionPlantTest { .doesNotThrowAnyException() } + @Test + fun `반려식물에게 레코드 타입의 히스토리를 직접 저장하려는 경우 예외 발생`() { + val waterCycle = 7L + val lastWaterDate = LocalDate.now() + val nextWaterDate = lastWaterDate.plusDays(waterCycle) + val companionPlant = CompanionPlant( + "https://nongsaro.go.kr/cms_contents/301/14687_MF_ATTACH_01.jpg", + "나의 아기 선인장", "shine", nextWaterDate, lastWaterDate, waterCycle.toInt() + ) + + assertThatThrownBy { + companionPlant.saveHistory(HistoryType.RECORDING, LocalDate.now()) + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("데일리 기록은 히스토리 타입을 직접 추가할 수 없습니다.") + } + @Test fun `물 준 주기가 맞지 않으면 예외 발생`() { val waterCycle = 7L diff --git a/src/test/kotlin/gdsc/plantory/plant/service/PlantServiceTest.kt b/src/test/kotlin/gdsc/plantory/plant/service/PlantServiceTest.kt new file mode 100644 index 0000000..102b04b --- /dev/null +++ b/src/test/kotlin/gdsc/plantory/plant/service/PlantServiceTest.kt @@ -0,0 +1,149 @@ +package gdsc.plantory.plant.service + +import gdsc.plantory.fixture.기록없는_테스트식물_ID +import gdsc.plantory.fixture.테스터_디바이스_토큰 +import gdsc.plantory.plant.domain.CompanionPlant +import gdsc.plantory.plant.domain.CompanionPlantRepository +import gdsc.plantory.plant.domain.HistoryType +import gdsc.plantory.plantInformation.domain.PlantInformation +import gdsc.plantory.plantInformation.domain.PlantInformationRepository +import gdsc.plantory.util.AcceptanceTest +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@DisplayName("서비스 : PlantService") +@Transactional +class PlantServiceTest( + @Autowired val plantService: PlantService, + @Autowired val companionPlantRepository: CompanionPlantRepository, + @Autowired val entityManager: EntityManager, + @Autowired val plantInformationRepository: PlantInformationRepository, +) : AcceptanceTest() { + + @Test + fun `데일리 기록 히스토리는 직접 추가할 수 없다`() { + // when, then + assertThatThrownBy { + plantService.createPlantHistory(기록없는_테스트식물_ID, 테스터_디바이스_토큰, HistoryType.RECORDING) + } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("데일리 기록은 히스토리 타입을 직접 추가할 수 없습니다.") + } + + @Test + fun `사용자는 식물의 데일리 기록을 조회하여 사진, 본문, 물준유무를 확인할 수 있다`() { + // given + val plantInformation = PlantInformation( + _species = "덕구리난", + _imageUrl = "https://nongsaro.go.kr/cms_contents/301/13336_MF_ATTACH_05.jpg", + _familyName = "백합과", + smell = "거의 없음", + poison = "없음", + manageLevel = "초보자", + growSpeed = "느림", + _requireTemp = "21~25℃", + _minimumTemp = "13℃ 이상", + requireHumidity = "40% 미만", + postingPlace = "거실 창측 (실내깊이 150~300cm),발코니 내측 (실내깊이 50~150cm),발코니 창측 (실내깊이 0~50cm)", + specialManageInfo = "적절한 환기가 필요함, 여름동안 햇볕이 잘드는 위치에 배치하는 것이 좋음.", + _waterCycleSpring = 4, + _waterCycleSummer = 3, + _waterCycleAutumn = 4, + _waterCycleWinter = 4, + ) + val savedPlantInformation = plantInformationRepository.save(plantInformation) + + val today = LocalDate.now() + val companionPlant = CompanionPlant( + _imageUrl = "https://nongsaro.go.kr/cms_contents/301/13336_MF_ATTACH_05.jpg", + _shortDescription = "덕구리난은 덕구리난과!", + _nickname = "shine", + birthDate = LocalDate.of(2024, 1, 1), + nextWaterDate = today, + lastWaterDate = LocalDate.of(2024, 1, 23), + waterCycle = 3, + plantInformationId = savedPlantInformation.getId, + memberId = 1L, + ) + companionPlant.saveRecord("test-record", "https://test.com", today) + companionPlant.saveHistory(HistoryType.WATER_CHANGE, today) + val savedPlant = companionPlantRepository.save(companionPlant) + + entityManager.flush() + entityManager.clear() + + // when + val result = plantService.lookupPlantRecordOfDate( + savedPlant.getId, today, "device-token" + ) + + // then + assertAll( + { assertThat(result.comment).isEqualTo("test-record") }, + { assertThat(result.imageUrl).isEqualTo("https://test.com") }, + { assertThat(result.water).isTrue() }, + ) + } + + @Test + fun `사용자는 식물의 데일리 기록을 조회했을때 물을 주지 않았던 경우 기록에 물주는 표시가 비어있다`() { + // given + val plantInformation = PlantInformation( + _species = "덕구리난", + _imageUrl = "https://nongsaro.go.kr/cms_contents/301/13336_MF_ATTACH_05.jpg", + _familyName = "백합과", + smell = "거의 없음", + poison = "없음", + manageLevel = "초보자", + growSpeed = "느림", + _requireTemp = "21~25℃", + _minimumTemp = "13℃ 이상", + requireHumidity = "40% 미만", + postingPlace = "거실 창측 (실내깊이 150~300cm),발코니 내측 (실내깊이 50~150cm),발코니 창측 (실내깊이 0~50cm)", + specialManageInfo = "적절한 환기가 필요함, 여름동안 햇볕이 잘드는 위치에 배치하는 것이 좋음.", + _waterCycleSpring = 4, + _waterCycleSummer = 3, + _waterCycleAutumn = 4, + _waterCycleWinter = 4, + ) + val savedPlantInformation = plantInformationRepository.save(plantInformation) + + val companionPlant = CompanionPlant( + _imageUrl = "https://nongsaro.go.kr/cms_contents/301/13336_MF_ATTACH_05.jpg", + _shortDescription = "덕구리난은 덕구리난과!", + _nickname = "shine", + birthDate = LocalDate.of(2024, 1, 1), + nextWaterDate = LocalDate.of(2024, 1, 25), + lastWaterDate = LocalDate.of(2024, 1, 23), + waterCycle = 3, + plantInformationId = savedPlantInformation.getId, + memberId = 1L, + ) + val today = LocalDate.now() + companionPlant.saveRecord("test-record", "https://test.com", today) + companionPlant.saveHistory(HistoryType.POT_CHANGE, today) + val savedPlant = companionPlantRepository.save(companionPlant) + + entityManager.flush() + entityManager.clear() + + // when + val result = plantService.lookupPlantRecordOfDate( + savedPlant.getId, today, "device-token" + ) + + // then + assertAll( + { assertThat(result.comment).isEqualTo("test-record") }, + { assertThat(result.imageUrl).isEqualTo("https://test.com") }, + { assertThat(result.water).isFalse() }, + ) + } +} diff --git a/src/test/kotlin/gdsc/plantory/util/DatabaseLoader.kt b/src/test/kotlin/gdsc/plantory/util/DatabaseLoader.kt index 4dea5bb..4b1e9b3 100644 --- a/src/test/kotlin/gdsc/plantory/util/DatabaseLoader.kt +++ b/src/test/kotlin/gdsc/plantory/util/DatabaseLoader.kt @@ -31,7 +31,6 @@ class DatabaseLoader( val testCompanionPlantHasNoHistories = CompanionPlantFixture.generateTestCompanionPlantHasNoHistories(2L) testCompanionPlantWillHaveHistories.saveRecord("test-record", "https://test.com") - testCompanionPlantWillHaveHistories.saveHistory(HistoryType.RECORDING) testCompanionPlantWillHaveHistories.saveHistory(HistoryType.POT_CHANGE) testCompanionPlantWillHaveHistories.saveHistory(HistoryType.WATER_CHANGE) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 63c6397..0665b37 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,10 @@ spring: username: plantory password: plantory1234 driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + servlet: + multipart: + max-file-size: 20MB + max-request-size: 25MB local: image: