diff --git a/build.gradle.kts b/build.gradle.kts index 72f54c5..a7b8acc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { // JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("org.postgresql:postgresql") // flyway (DB schema migration) implementation("org.flywaydb:flyway-core") @@ -90,7 +91,7 @@ dependencies { testImplementation("io.kotest.extensions:kotest-extensions-spring:$kotestExtensionsVersion") testImplementation("io.mockk:mockk:$mockkVersion") testRuntimeOnly("com.h2database:h2") - runtimeOnly("org.postgresql:postgresql") + testImplementation("io.rest-assured:rest-assured") } spotless { diff --git a/src/main/kotlin/com/yapp2app/common/annotation/UseCase.kt b/src/main/kotlin/com/yapp2app/common/annotation/UseCase.kt new file mode 100644 index 0000000..6c1deb4 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/annotation/UseCase.kt @@ -0,0 +1,14 @@ +package com.yapp2app.common.annotation + +import org.springframework.stereotype.Component + +/** + * fileName : UseCase + * author : koo + * date : 2025. 12. 23. 오후 6:51 + * description : usecase annotation + */ +@Component +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class UseCase diff --git a/src/main/kotlin/com/yapp2app/common/api/document/RequiresSecurity.kt b/src/main/kotlin/com/yapp2app/common/api/document/RequiresSecurity.kt index 98c7b92..60accf1 100644 --- a/src/main/kotlin/com/yapp2app/common/api/document/RequiresSecurity.kt +++ b/src/main/kotlin/com/yapp2app/common/api/document/RequiresSecurity.kt @@ -1,5 +1,5 @@ package com.yapp2app.common.api.document -@Target(AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class RequiresSecurity diff --git a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt index d995e16..2966161 100644 --- a/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt +++ b/src/main/kotlin/com/yapp2app/common/api/dto/ResultCode.kt @@ -17,6 +17,8 @@ enum class ResultCode(val code: String, var message: String) { NOT_FOUND("D-04", "데이터를 찾을 수 없습니다."), ALREADY_REQUEST("D-05", "이미 처리된 요청입니다."), + CONFLICT_FOLDER("D-06", message = "해당하는 폴더가 존재합니다."), + EXPIRED_TOKEN_ERROR("D-997", "토큰이 만료되었습니다."), INVALID_TOKEN_ERROR("D-998", "토큰이 올바르지 않습니다."), SECURITY_ERROR("D-999", "인증에 실패하였습니다."), diff --git a/src/main/kotlin/com/yapp2app/common/config/JasyptConfig.kt b/src/main/kotlin/com/yapp2app/common/config/JasyptConfig.kt index d27e057..8bf0a46 100644 --- a/src/main/kotlin/com/yapp2app/common/config/JasyptConfig.kt +++ b/src/main/kotlin/com/yapp2app/common/config/JasyptConfig.kt @@ -6,6 +6,7 @@ import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile /** * fileName : JasyptConfig @@ -13,6 +14,7 @@ import org.springframework.context.annotation.Configuration * date : 2025. 12. 24. * description : Jasypt 암호화 설정 */ +@Profile("!test") @Configuration class JasyptConfig( @Value("\${jasypt.encryptor.password}") diff --git a/src/main/kotlin/com/yapp2app/common/domain/BaseTimeEntity.kt b/src/main/kotlin/com/yapp2app/common/domain/BaseTimeEntity.kt new file mode 100644 index 0000000..1fc5e78 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/domain/BaseTimeEntity.kt @@ -0,0 +1,27 @@ +package com.yapp2app.common.domain + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +/** + * fileName : BaseTimeEntity + * author : koo + * date : 2025. 12. 23. 오후 7:09 + * description : 생성일, 수정일 base entity + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseTimeEntity( + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: LocalDateTime? = null, + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime? = null, +) diff --git a/src/main/kotlin/com/yapp2app/common/exception/dto/ExceptionDto.kt b/src/main/kotlin/com/yapp2app/common/exception/dto/ExceptionDto.kt index ad57cec..c5ca129 100644 --- a/src/main/kotlin/com/yapp2app/common/exception/dto/ExceptionDto.kt +++ b/src/main/kotlin/com/yapp2app/common/exception/dto/ExceptionDto.kt @@ -12,7 +12,7 @@ data class FieldErrorDetail(var field: String, var message: String) data class ExceptionMsg( val message: String, - val code: String, + val resultCode: String, val success: Boolean, val errors: List? = null, ) : Serializable diff --git a/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt b/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt index 5d740b0..f6174ca 100644 --- a/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt +++ b/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt @@ -33,7 +33,7 @@ class ExceptionHandler { fun businessExceptionHandler(ex: BusinessException): ResponseEntity { val temp = ResponseEntity( ExceptionMsg( - code = ex.resultCode.code, + resultCode = ex.resultCode.code, message = ex.resultCode.message, success = false, errors = emptyList(), @@ -59,7 +59,7 @@ class ExceptionHandler { return ResponseEntity( ExceptionMsg( - code = ResultCode.INVALID_PARAMETER.code, + resultCode = ResultCode.INVALID_PARAMETER.code, message = errors.get(0).message, success = false, errors = errors, @@ -104,7 +104,7 @@ class ExceptionHandler { } return ResponseEntity( ExceptionMsg( - code = ResultCode.INVALID_PARAMETER.code, + resultCode = ResultCode.INVALID_PARAMETER.code, message = ResultCode.INVALID_PARAMETER.message, success = false, errors = errors, @@ -119,7 +119,7 @@ class ExceptionHandler { request: WebRequest, ): ResponseEntity = ResponseEntity( ExceptionMsg( - code = ResultCode.INVALID_PARAMETER.code, + resultCode = ResultCode.INVALID_PARAMETER.code, message = ResultCode.INVALID_PARAMETER.message, success = false, errors = listOf( @@ -139,7 +139,7 @@ class ExceptionHandler { val enumValues = ex.requiredType!!.enumConstants?.joinToString(", ") ResponseEntity( ExceptionMsg( - code = ResultCode.INVALID_PARAMETER.code, + resultCode = ResultCode.INVALID_PARAMETER.code, message = ResultCode.INVALID_PARAMETER.message, success = false, errors = listOf( @@ -154,7 +154,7 @@ class ExceptionHandler { } else { ResponseEntity( ExceptionMsg( - code = ResultCode.INVALID_PARAMETER.code, + resultCode = ResultCode.INVALID_PARAMETER.code, message = ResultCode.INVALID_PARAMETER.message, success = false, errors = listOf( diff --git a/src/main/kotlin/com/yapp2app/common/infra/config/JpaAuditingConfig.kt b/src/main/kotlin/com/yapp2app/common/infra/config/JpaAuditingConfig.kt new file mode 100644 index 0000000..3df2150 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/infra/config/JpaAuditingConfig.kt @@ -0,0 +1,14 @@ +package com.yapp2app.common.infra.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +/** + * fileName : JpaAuditingConfig + * author : koo + * date : 2025. 12. 23. 오후 7:10 + * description : Jpa Auditing Config + */ +@Configuration +@EnableJpaAuditing +class JpaAuditingConfig diff --git a/src/main/kotlin/com/yapp2app/common/infra/media/fake/FakeS3MediaStorage.kt b/src/main/kotlin/com/yapp2app/common/infra/media/fake/FakeS3MediaStorage.kt new file mode 100644 index 0000000..b02f669 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/common/infra/media/fake/FakeS3MediaStorage.kt @@ -0,0 +1,14 @@ +package com.yapp2app.common.infra.media.fake + +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +/** + * fileName : FakeS3MediaStorage + * author : koo + * date : 2025. 12. 28. 오후 10:30 + * description : + */ +@Profile("test") +@Configuration +class FakeS3MediaStorage diff --git a/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt b/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt new file mode 100644 index 0000000..9e8bd52 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/media/domain/entity/Media.kt @@ -0,0 +1,70 @@ +package com.yapp2app.media.domain.entity + +import com.yapp2app.common.domain.BaseTimeEntity +import com.yapp2app.media.domain.MediaType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * fileName : Media + * author : koo + * date : 2026. 1. 2. + * description : 미디어 파일 엔티티. fileId로 접근 + */ +@Entity +@Table(name = "TB_MEDIA") +class Media( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "storage_key", nullable = false, length = 255) + val storageKey: String, + + @Column(name = "owner_id", nullable = false) + val ownerId: Long, + + @Column(name = "media_type", nullable = false, length = 30) + @Enumerated(EnumType.STRING) + val mediaType: MediaType, + + @Column(name = "status", nullable = false, length = 30) + @Enumerated(EnumType.STRING) + var status: MediaStatus = MediaStatus.INITIATED, + + @Column(name = "content_type", nullable = true, length = 100) + val contentType: String, +) : BaseTimeEntity() { + + fun markAsUploaded() { + this.status = MediaStatus.UPLOADED + } + + fun markAsFailed() { + this.status = MediaStatus.FAILED + } + + fun markAsDeleteRequested() { + this.status = MediaStatus.DELETE_REQUESTED + } + + fun markAsDeleted() { + this.status = MediaStatus.DELETED + } + + fun isUploaded(): Boolean = status == MediaStatus.UPLOADED +} + +enum class MediaStatus { + INITIATED, // ticket 발급됨(업로드 시작 전) + UPLOADED, // 업로드 완료 콜백/완료 API 호출됨 + FAILED, // 실패 + DELETE_REQUESTED, // 삭제 요청됨 + DELETED, // 삭제 완료 +} diff --git a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt b/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt index 97ea1e8..03de0cb 100644 --- a/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt +++ b/src/main/kotlin/com/yapp2app/media/infra/s3/S3MediaStorageConfig.kt @@ -3,6 +3,7 @@ package com.yapp2app.media.infra.s3 import com.yapp2app.media.application.port.MediaStoragePort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.regions.Region @@ -17,6 +18,7 @@ import java.net.URI * date : 2025. 12. 19. 오전 2:40 * description : S3 설정 */ +@Profile("!test") @Configuration class S3MediaStorageConfig(private val props: S3Properties) { diff --git a/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt b/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt new file mode 100644 index 0000000..6190c71 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt @@ -0,0 +1,129 @@ +package com.yapp2app.photo.api.controller + +import com.yapp2app.common.api.document.RequiresSecurity +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.photo.api.converter.FolderCommandConverter +import com.yapp2app.photo.api.converter.FolderResultConverter +import com.yapp2app.photo.api.dto.CreateFolderRequest +import com.yapp2app.photo.api.dto.CreateFolderResponse +import com.yapp2app.photo.api.dto.DeleteFoldersRequest +import com.yapp2app.photo.api.dto.GetAllFolderResponse +import com.yapp2app.photo.api.dto.UpdateFolderRequest +import com.yapp2app.photo.application.usecase.CreateFolderUseCase +import com.yapp2app.photo.application.usecase.DeleteFolderUseCase +import com.yapp2app.photo.application.usecase.GetFoldersUseCase +import com.yapp2app.photo.application.usecase.UpdateFolderUseCase +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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 +import org.springframework.web.bind.annotation.RestController + +/** + * fileName : FolderController + * author : koo + * date : 2025. 12. 23. 오후 7:58 + * description : Folder aggregate에 대한 api endpoint + */ +@RequiresSecurity +@Tag(name = "folder", description = "폴더 API") +@RestController +@RequestMapping("/api/folders") +class FolderController( + private val createFolderUseCase: CreateFolderUseCase, + private val getFoldersUseCase: GetFoldersUseCase, + private val deleteFolderUseCase: DeleteFolderUseCase, + private val updateFolderUseCase: UpdateFolderUseCase, + private val commandConverter: FolderCommandConverter, + private val resultConverter: FolderResultConverter, +) { + + @Operation( + summary = "폴더 생성 API", + description = "폴더를 생성합니다.", + ) + @PostMapping + fun createFolder( + @AuthenticationPrincipal(expression = "id") userId: Long, + @Valid @RequestBody request: CreateFolderRequest, + ): BaseResponse { + val command = commandConverter.toCreateFolderCommand(request, userId) + + val result = createFolderUseCase.execute(command) + + val response = resultConverter.toCreateFolderResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "폴더 목록 조회 API", + description = "폴더 목록을 조회합니다.", + ) + @GetMapping + fun getAllFolder(@AuthenticationPrincipal(expression = "id") userId: Long): BaseResponse { + val command = commandConverter.toGetFoldersCommand(userId) + + val result = getFoldersUseCase.execute(command) + + val response = resultConverter.toGetAllFoldersResponse(result) + + return BaseResponse(data = response) + } + + @Operation( + summary = "폴더 삭제 API", + description = "단건 폴더 삭제를 합니다.", + ) + @DeleteMapping("/{folderId}") + fun deleteFolder( + @AuthenticationPrincipal(expression = "id") userId: Long, + @PathVariable folderId: Long, + ): BaseResponse { + val command = commandConverter.toDeleteFolderCommand(userId, folderId) + + deleteFolderUseCase.execute(command) + + return BaseResponse() + } + + @Operation( + summary = "폴더 선택 삭제 API", + description = "여러 개의 폴더를 선택하여 삭제합니다.", + ) + @DeleteMapping + fun deleteFolders( + @AuthenticationPrincipal(expression = "id") userId: Long, + @Valid @RequestBody request: DeleteFoldersRequest, + ): BaseResponse { + val command = commandConverter.toDeleteFoldersCommand(request, userId) + + deleteFolderUseCase.execute(command) + + return BaseResponse() + } + + @Operation( + summary = "폴더 갱신 API", + description = "폴더 정보를 갱신합니다.", + ) + @PatchMapping("/{folderId}") + fun updateFolder( + @AuthenticationPrincipal(expression = "id") userId: Long, + @PathVariable folderId: Long, + @Valid @RequestBody request: UpdateFolderRequest, + ): BaseResponse { + val command = commandConverter.toUpdateFolderCommand(request, folderId, userId) + + updateFolderUseCase.execute(command) + + return BaseResponse() + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt new file mode 100644 index 0000000..2d3256f --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt @@ -0,0 +1,34 @@ +package com.yapp2app.photo.api.converter + +import com.yapp2app.photo.api.dto.CreateFolderRequest +import com.yapp2app.photo.api.dto.DeleteFoldersRequest +import com.yapp2app.photo.api.dto.UpdateFolderRequest +import com.yapp2app.photo.application.command.CreateFolderCommand +import com.yapp2app.photo.application.command.DeleteFolderCommand +import com.yapp2app.photo.application.command.DeleteFoldersCommand +import com.yapp2app.photo.application.command.GetFoldersCommand +import com.yapp2app.photo.application.command.UpdateFolderCommand +import org.springframework.stereotype.Component + +/** + * fileName : FolderCommandConverter + * author : koo + * date : 2025. 12. 28. 오후 9:44 + * description : + */ +@Component +class FolderCommandConverter { + + fun toCreateFolderCommand(request: CreateFolderRequest, userId: Long): CreateFolderCommand = + CreateFolderCommand(userId, request.name) + + fun toGetFoldersCommand(userId: Long): GetFoldersCommand = GetFoldersCommand(userId) + + fun toDeleteFolderCommand(userId: Long, folderId: Long) = DeleteFolderCommand(userId, folderId) + + fun toDeleteFoldersCommand(request: DeleteFoldersRequest, userId: Long) = + DeleteFoldersCommand(userId, request.folderIds) + + fun toUpdateFolderCommand(request: UpdateFolderRequest, folderId: Long, userId: Long) = + UpdateFolderCommand(userId, folderId, request.name) +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/FolderResultConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderResultConverter.kt new file mode 100644 index 0000000..b908bb7 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderResultConverter.kt @@ -0,0 +1,28 @@ +package com.yapp2app.photo.api.converter + +import com.yapp2app.photo.api.dto.CreateFolderResponse +import com.yapp2app.photo.api.dto.GetAllFolderResponse +import com.yapp2app.photo.application.result.CreateFolderResult +import com.yapp2app.photo.application.result.GetFoldersResult +import org.springframework.stereotype.Component + +/** + * fileName : FolderDtoMapper + * author : koo + * date : 2025. 12. 28. 오후 9:41 + * description : + */ +@Component +class FolderResultConverter { + + fun toGetAllFoldersResponse(result: GetFoldersResult): GetAllFolderResponse = GetAllFolderResponse( + result.items.map { + GetAllFolderResponse.FolderInfo( + it.folderId, + it.name, + ) + }, + ) + + fun toCreateFolderResponse(result: CreateFolderResult): CreateFolderResponse = CreateFolderResponse(result.folderId) +} diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt new file mode 100644 index 0000000..05b483c --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt @@ -0,0 +1,28 @@ +package com.yapp2app.photo.api.dto + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty + +/** + * fileName : FolderRequest + * author : koo + * date : 2025. 12. 23. 오후 10:27 + * description : 폴더 관련 요청 body + */ +data class CreateFolderRequest( + @field:Schema(description = "폴더명", example = "즐겨찾기") + @field:NotBlank val name: String, +) + +data class DeleteFoldersRequest( + @field:Schema( + description = "삭제할 폴더 ID 목록", + example = "[1, 2, 3]", + ) @field:NotEmpty val folderIds: List, +) + +data class UpdateFolderRequest( + @field:Schema(description = "변경할 폴더명", example = "대학교 친구") + @field:NotBlank val name: String, +) diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/FolderResponse.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderResponse.kt new file mode 100644 index 0000000..230de5a --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderResponse.kt @@ -0,0 +1,13 @@ +package com.yapp2app.photo.api.dto + +/** + * fileName : PhotoBoothResponse + * author : koo + * date : 2025. 12. 23. 오후 6:50 + * description : Folder aggregate에 대한 응답 (TODO : data class 추가에 따라 파일명 변경) + */ +data class CreateFolderResponse(val folderId: Long) + +data class GetAllFolderResponse(val items: List) { + data class FolderInfo(val folderId: Long, val name: String) +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt b/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt new file mode 100644 index 0000000..332a154 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt @@ -0,0 +1,17 @@ +package com.yapp2app.photo.application.command + +/** + * fileName : FolderCommand + * author : koo + * date : 2025. 12. 23. 오후 8:44 + * description : Folder usecase 관련 command + */ +data class CreateFolderCommand(val userId: Long, val name: String) + +data class DeleteFolderCommand(val userId: Long, val folderId: Long) + +data class DeleteFoldersCommand(val userId: Long, val folderIds: List) + +data class GetFoldersCommand(val userId: Long) + +data class UpdateFolderCommand(val userId: Long, val folderId: Long, val newName: String) diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt new file mode 100644 index 0000000..a0d9931 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt @@ -0,0 +1,24 @@ +package com.yapp2app.photo.application.port + +import com.yapp2app.photo.domain.entity.Folder + +/** + * fileName : FolderRepositoryPort + * author : koo + * date : 2025. 12. 23. 오후 7:58 + * description : Folder 영속성 관련 포트 (command + query) + */ +interface FolderRepositoryPort { + + fun save(folder: Folder): Folder + + fun deleteOwnedFolder(userId: Long, folderId: Long) + fun deleteOwnedFolders(userId: Long, folderIds: List) + + fun listOwnedFolders(userId: Long): List + + fun getOwnedFolder(userId: Long, folderId: Long): Folder? + fun getOwnedFolders(userId: Long, folderIds: List): List + + fun existsOwnedFolderName(userId: Long, name: String): Boolean +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/result/FolderResult.kt b/src/main/kotlin/com/yapp2app/photo/application/result/FolderResult.kt new file mode 100644 index 0000000..1abab60 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/result/FolderResult.kt @@ -0,0 +1,13 @@ +package com.yapp2app.photo.application.result + +/** + * fileName : GetFoldersResult + * author : koo + * date : 2025. 12. 23. 오후 8:10 + * description : Folder usecase 관련 result (TODO : 파일 추가에 따른 파일명 변경) + */ +data class CreateFolderResult(val folderId: Long) + +data class GetFoldersResult(val items: List) { + data class FolderInfo(val folderId: Long, val name: String) +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/CreateFolderUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/CreateFolderUseCase.kt new file mode 100644 index 0000000..7c86858 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/CreateFolderUseCase.kt @@ -0,0 +1,36 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.photo.application.command.CreateFolderCommand +import com.yapp2app.photo.application.port.FolderRepositoryPort +import com.yapp2app.photo.application.result.CreateFolderResult +import com.yapp2app.photo.domain.entity.Folder +import org.springframework.transaction.annotation.Transactional + +/** + * fileName : CreateFolderUseCase + * author : koo + * date : 2025. 12. 23. 오후 7:58 + * description : 폴더 생성 usecase + */ +@UseCase +class CreateFolderUseCase(private val folderRepository: FolderRepositoryPort) { + + @Transactional + fun execute(command: CreateFolderCommand): CreateFolderResult { + if (folderRepository.existsOwnedFolderName(command.userId, command.name)) { + throw BusinessException(ResultCode.CONFLICT_FOLDER) + } + + val savedFolder = folderRepository.save( + Folder( + userId = command.userId, + name = command.name, + ), + ) + + return CreateFolderResult(savedFolder.id!!) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt new file mode 100644 index 0000000..e77b023 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/DeleteFolderUseCase.kt @@ -0,0 +1,38 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.photo.application.command.DeleteFolderCommand +import com.yapp2app.photo.application.command.DeleteFoldersCommand +import com.yapp2app.photo.application.port.FolderRepositoryPort +import org.springframework.transaction.annotation.Transactional + +/** + * fileName : DeleteFolderUseCase + * author : koo + * date : 2025. 12. 23. 오후 8:33 + * description : 폴더 삭제 usecase + */ +@UseCase +class DeleteFolderUseCase(private val folderRepository: FolderRepositoryPort) { + + @Transactional + fun execute(command: DeleteFolderCommand) { + val folder = folderRepository.getOwnedFolder(command.userId, command.folderId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + + folderRepository.deleteOwnedFolder(command.userId, command.folderId) + } + + @Transactional + fun execute(command: DeleteFoldersCommand) { + val folders = folderRepository.getOwnedFolders(command.userId, command.folderIds) + + if (folders.size != command.folderIds.size) { + throw BusinessException(ResultCode.NOT_FOUND) + } + + folderRepository.deleteOwnedFolders(command.userId, command.folderIds) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt new file mode 100644 index 0000000..1767d05 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt @@ -0,0 +1,26 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.photo.application.command.GetFoldersCommand +import com.yapp2app.photo.application.port.FolderRepositoryPort +import com.yapp2app.photo.application.result.GetFoldersResult +import org.springframework.transaction.annotation.Transactional + +/** + * fileName : GetFoldersUseCase + * author : koo + * date : 2025. 12. 23. 오후 8:04 + * description : 폴더 목록 조회 usecase (TODO : 필요에 따라 Paging 추가) + */ +@UseCase +class GetFoldersUseCase(private val folderRepository: FolderRepositoryPort) { + + @Transactional(readOnly = true) + fun execute(command: GetFoldersCommand): GetFoldersResult { + val folders = folderRepository.listOwnedFolders(command.userId) + .map { GetFoldersResult.FolderInfo(it.id!!, it.name) } + .toList() + + return GetFoldersResult(folders) + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdateFolderUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdateFolderUseCase.kt new file mode 100644 index 0000000..40ebc5d --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/UpdateFolderUseCase.kt @@ -0,0 +1,33 @@ +package com.yapp2app.photo.application.usecase + +import com.yapp2app.common.annotation.UseCase +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.common.exception.BusinessException +import com.yapp2app.photo.application.command.UpdateFolderCommand +import com.yapp2app.photo.application.port.FolderRepositoryPort +import org.springframework.transaction.annotation.Transactional + +/** + * fileName : UpdateFolderUseCase + * author : koo + * date : 2025. 12. 23. 오후 10:23 + * description : folder update usecase + */ +@UseCase +class UpdateFolderUseCase(private val folderRepository: FolderRepositoryPort) { + + @Transactional + fun execute(command: UpdateFolderCommand) { + val folder = folderRepository.getOwnedFolder(command.userId, command.folderId) + ?: throw BusinessException(ResultCode.NOT_FOUND) + + // 변경하려는 이름이 현재와 다르고, 이미 존재하는 경우 + if (folder.name != command.newName && + folderRepository.existsOwnedFolderName(command.userId, command.newName) + ) { + throw BusinessException(ResultCode.CONFLICT_FOLDER) + } + + folder.name = command.newName + } +} diff --git a/src/main/kotlin/com/yapp2app/photo/domain/entity/Folder.kt b/src/main/kotlin/com/yapp2app/photo/domain/entity/Folder.kt new file mode 100644 index 0000000..4b1fcd0 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/domain/entity/Folder.kt @@ -0,0 +1,32 @@ +package com.yapp2app.photo.domain.entity + +import com.yapp2app.common.domain.BaseTimeEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * fileName : Folder + * author : koo + * date : 2025. 12. 23. 오후 7:24 + * description : 사용자의 사진 폴더 엔티티. 한 사용자는 여러 폴더를 가질 수 있으며, 폴더는 여러 PhotoImage를 포함 + */ +@Entity +@Table(name = "TB_folder") +class Folder( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "name", nullable = false) + var name: String, + + @Column(name = "cover_photo_id") + var coverPhotoId: Long? = null, +) : BaseTimeEntity() diff --git a/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt b/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt new file mode 100644 index 0000000..591ec1b --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/domain/entity/PhotoImage.kt @@ -0,0 +1,32 @@ +package com.yapp2app.photo.domain.entity + +import com.yapp2app.common.domain.BaseTimeEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * fileName : PhotoImage + * author : koo + * date : 2025. 12. 23. 오후 7:13 + * description : 사용자의 사진 엔티티. url 대신 fileId로 접근 + */ +@Entity +@Table(name = "TB_photo_image") +class PhotoImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "user_id", nullable = false) + val userId: Long, + + @Column(name = "file_id", nullable = false, length = 64, unique = true) + val fileId: String, + + @Column(name = "folder_id", nullable = true) + var folderId: Long? = null, +) : BaseTimeEntity() diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt new file mode 100644 index 0000000..4e15b19 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt @@ -0,0 +1,36 @@ +package com.yapp2app.photo.infra.persist + +import com.yapp2app.photo.application.port.FolderRepositoryPort +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository +import org.springframework.stereotype.Repository + +/** + * fileName : FolderRepositoryAdapter + * author : koo + * date : 2025. 12. 23. 오후 7:59 + * description : File 영속성에 대한 Adapter (command + query) + */ +@Repository +class FolderRepositoryAdapter(private val jpaRepository: JpaFolderRepository) : FolderRepositoryPort { + + override fun save(folder: Folder): Folder = jpaRepository.save(folder) + + override fun deleteOwnedFolder(userId: Long, folderId: Long) { + jpaRepository.deleteByUserIdAndId(userId, folderId) + } + + override fun deleteOwnedFolders(userId: Long, folderIds: List) { + jpaRepository.deleteAllByUserIdAndIdIn(userId, folderIds) + } + + override fun listOwnedFolders(userId: Long): List = jpaRepository.findAllByUserId(userId) + + override fun getOwnedFolders(userId: Long, folderIds: List): List = + jpaRepository.findAllByUserIdAndIdIn(userId, folderIds) + + override fun getOwnedFolder(userId: Long, folderId: Long) = jpaRepository.findByUserIdAndId(userId, folderId) + + override fun existsOwnedFolderName(userId: Long, name: String): Boolean = + jpaRepository.existsByUserIdAndName(userId, name) +} diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt new file mode 100644 index 0000000..24f3de1 --- /dev/null +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/JpaFolderRepository.kt @@ -0,0 +1,25 @@ +package com.yapp2app.photo.infra.persist.jpa + +import com.yapp2app.photo.domain.entity.Folder +import org.springframework.data.jpa.repository.JpaRepository + +/** + * fileName : JpaFolderRepository + * author : koo + * date : 2025. 12. 23. 오후 7:59 + * description : File에 대한 Jpa interface + */ +interface JpaFolderRepository : JpaRepository { + + fun findByUserIdAndId(userId: Long, folderId: Long): Folder? + + fun findAllByUserId(userId: Long): List + + fun findAllByUserIdAndIdIn(userId: Long, folderIds: List): List + + fun existsByUserIdAndName(userId: Long, name: String): Boolean + + fun deleteByUserIdAndId(userId: Long, folderId: Long) + + fun deleteAllByUserIdAndIdIn(userId: Long, folderIds: List) +} diff --git a/src/main/kotlin/com/yapp2app/user/domain/enums/ProviderType.kt b/src/main/kotlin/com/yapp2app/user/domain/enums/ProviderType.kt index 37fb1f1..f6f262f 100644 --- a/src/main/kotlin/com/yapp2app/user/domain/enums/ProviderType.kt +++ b/src/main/kotlin/com/yapp2app/user/domain/enums/ProviderType.kt @@ -4,4 +4,5 @@ enum class ProviderType { LOCAL, APPLE, KAKAO, + TEST, } diff --git a/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql b/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql new file mode 100644 index 0000000..2dc0e80 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_folder_and_photo_image_tables.sql @@ -0,0 +1,67 @@ +-- Create folder table +CREATE TABLE TB_FOLDER ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + cover_photo_id BIGINT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uk_folder_user_name UNIQUE (user_id, name) +); + +-- Add comments for folder table +COMMENT ON TABLE TB_FOLDER IS '사용자 사진 폴더 테이블'; +COMMENT ON COLUMN TB_FOLDER.id IS '폴더 고유 ID'; +COMMENT ON COLUMN TB_FOLDER.user_id IS '사용자 ID'; +COMMENT ON COLUMN TB_FOLDER.name IS '폴더 이름'; +COMMENT ON COLUMN TB_FOLDER.created_at IS '생성일시'; +COMMENT ON COLUMN TB_FOLDER.updated_at IS '수정일시'; + +-- Create photo_image table +CREATE TABLE TB_PHOTO_IMAGE ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + file_id VARCHAR(64) NOT NULL UNIQUE, + folder_id BIGINT, + status VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add comments for photo_image table +COMMENT ON TABLE TB_PHOTO_IMAGE IS '사용자 사진 이미지 테이블'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.id IS '사진 고유 ID'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.user_id IS '사용자 ID'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.file_id IS '파일 고유 ID (S3 키 등)'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.folder_id IS '폴더 ID (nullable, 폴더 삭제 시 NULL)'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.status IS '업로드 상태 (INITIATED, UPLOADED, VERIFIED, FAILED)'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.created_at IS '생성일시'; +COMMENT ON COLUMN TB_PHOTO_IMAGE.updated_at IS '수정일시'; + +-- Create media table +CREATE TABLE TB_MEDIA ( + id BIGSERIAL PRIMARY KEY, + storage_key VARCHAR(255) NOT NULL, + owner_id BIGINT NOT NULL, + media_type VARCHAR(30) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'INITIATED', + content_type VARCHAR(100) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add index for media table +CREATE INDEX idx_media_owner_id ON TB_MEDIA(owner_id); +CREATE INDEX idx_media_storage_key ON TB_MEDIA(storage_key); + +-- Add comments for media table +COMMENT ON TABLE TB_MEDIA IS '미디어 파일 테이블'; +COMMENT ON COLUMN TB_MEDIA.id IS '미디어 고유 ID'; +COMMENT ON COLUMN TB_MEDIA.storage_key IS '스토리지 키 (S3 키 등)'; +COMMENT ON COLUMN TB_MEDIA.owner_id IS '소유자 ID'; +COMMENT ON COLUMN TB_MEDIA.media_type IS '미디어 타입 (USER_PROFILE, PHOTO_BOOTH)'; +COMMENT ON COLUMN TB_MEDIA.status IS '상태 (INITIATED, UPLOADED, FAILED, DELETE_REQUESTED, DELETED)'; +COMMENT ON COLUMN TB_MEDIA.content_type IS '컨텐츠 타입 (image/jpeg, image/png 등)'; +COMMENT ON COLUMN TB_MEDIA.created_at IS '생성일시'; +COMMENT ON COLUMN TB_MEDIA.updated_at IS '수정일시'; diff --git a/src/test/kotlin/com/yapp2app/JasyptTest.kt b/src/test/kotlin/com/yapp2app/JasyptTest.kt index cfb4067..1020d3f 100644 --- a/src/test/kotlin/com/yapp2app/JasyptTest.kt +++ b/src/test/kotlin/com/yapp2app/JasyptTest.kt @@ -1,14 +1,27 @@ package com.yapp2app -import org.jasypt.encryption.StringEncryptor +import org.jasypt.encryption.pbe.PooledPBEStringEncryptor +import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles -@SpringBootTest @ActiveProfiles("test") -class JasyptTest(@Autowired private val jasyptStringEncryptor: StringEncryptor) { +class JasyptTest { + + private val jasyptStringEncryptor = PooledPBEStringEncryptor().apply { + setConfig( + SimpleStringPBEConfig().apply { + password = "testPasswordForJasypt" + algorithm = "PBEWithHmacSHA512AndAES_256" + setKeyObtentionIterations("1000") + setPoolSize("1") + providerName = "SunJCE" + setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator") + setIvGeneratorClassName("org.jasypt.iv.RandomIvGenerator") + stringOutputType = "base64" + }, + ) + } @Test fun jasyptGeneratTest() { diff --git a/src/test/kotlin/com/yapp2app/YappApp2SeverApplicationTests.kt b/src/test/kotlin/com/yapp2app/YappApp2SeverApplicationTests.kt index 2b2a5ea..56bbe6e 100644 --- a/src/test/kotlin/com/yapp2app/YappApp2SeverApplicationTests.kt +++ b/src/test/kotlin/com/yapp2app/YappApp2SeverApplicationTests.kt @@ -2,7 +2,9 @@ package com.yapp2app import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +@ActiveProfiles("test") @SpringBootTest class YappApp2SeverApplicationTests { diff --git a/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt b/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt new file mode 100644 index 0000000..f6d0ed9 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/E2ETestBase.kt @@ -0,0 +1,69 @@ +package com.yapp2app.e2e + +import com.yapp2app.auth.infra.security.token.AuthTokenProvider +import com.yapp2app.user.application.repository.UserRepository +import com.yapp2app.user.domain.entity.User +import com.yapp2app.user.domain.enums.ProviderType +import com.yapp2app.user.domain.enums.RoleType +import org.junit.jupiter.api.AfterEach +import org.springframework.beans.factory.annotation.Autowired + +/** + * fileName : E2ETestBase + * author : koo + * date : 2025. 12. 28. 오후 10:50 + * description : E2E test 편의를 Base class, 공통 기능을 상속하여 사용 + */ +abstract class E2ETestBase { + + @Autowired + protected lateinit var tokenProvider: AuthTokenProvider + + @Autowired + protected lateinit var userRepository: UserRepository + + @AfterEach + protected open fun tearDown() { + userRepository.deleteAllInBatch() + } + + fun createTestUserAndToken( + email: String = "test-${System.currentTimeMillis()}@example.com", + name: String = "테스트 사용자", + password: String = "Test1234!", + providerType: ProviderType = ProviderType.TEST, + roles: String = RoleType.USER.role, + ): Pair { + val user = userRepository.save( + createUser( + email, + name, + password, + providerType, + roles, + ), + ) + + val token = tokenProvider.createToken( + id = user.id.toString(), + roles = listOf(user.roles.split(",")[0]), + providerType = user.providerType, + ) + + return user to token + } + + private fun createUser( + email: String, + name: String, + password: String, + providerType: ProviderType, + roles: String, + ): User = User( + email = email, + name = name, + password = password, + providerType = providerType, + roles = roles, + ) +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt new file mode 100644 index 0000000..62be057 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt @@ -0,0 +1,143 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.photo.api.dto.CreateFolderRequest +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : CreateFolderE2ETest + * author : koo + * date : 2025. 12. 28. 오후 10:14 + * description : 폴더 생성 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class CreateFolderE2ETest : FolderE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("유효한 폴더명으로 폴더 생성 요청 시 성공 응답을 반환한다") + fun givenValidFolderName_whenCreateFolder_thenReturnsSuccess() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest("즐겨찾기")) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("여러 폴더를 연속으로 생성할 수 있다") + fun givenMultipleFolderRequests_whenCreateFolders_thenAllSucceed() { + val names = listOf("친구", "가족", "회사") + + names.forEach { folderName -> + val request = CreateFolderRequest(name = folderName) + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(request) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + } + + @Test + @DisplayName("빈 폴더명으로 폴더 생성 요청 시 400 에러를 반환한다") + fun givenBlankFolderName_whenCreateFolder_thenReturnsBadRequest() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest(name = "")) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("폴더명이 null인 경우 400에러를 반환한다") + fun givenNullFolderName_whenCreateFolder_thenReturnsBadRequest() { + val requestBody = """ + { + "name": null + } + """.trimIndent() + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(requestBody) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("폴더명이 이미 존재하는 경우 Conflict 에러를 반환한다") + fun givenDuplicateFolderName_whenCreateFolder_thenThrowConflict() { + val name = "즐겨찾기" + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest(name)) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + + // 동일한 이름으로 재요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest(name)) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.CONFLICT_FOLDER.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFolderE2ETest.kt new file mode 100644 index 0000000..0fed792 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFolderE2ETest.kt @@ -0,0 +1,115 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.photo.api.dto.CreateFolderRequest +import com.yapp2app.photo.api.dto.CreateFolderResponse +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.common.mapper.TypeRef +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : DeleteFolderE2ETest + * author : koo + * date : 2025. 12. 29. + * description : 단건 폴더 삭제 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class DeleteFolderE2ETest : FolderE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // Given: 테스트용 사용자 생성 및 토큰 발급 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("존재하는 폴더 삭제 시 성공 응답을 반환한다") + fun givenExistingFolder_whenDeleteFolder_thenReturnsSuccess() { + // Given: 폴더 생성 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest("삭제할 폴더")) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + .extract() + .`as`(object : TypeRef>() {}) + + // When & Then: 폴더 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/folders/${response.data?.folderId}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("존재하지 않는 폴더 삭제 시 400 에러를 반환한다") + fun givenNonExistentFolder_whenDeleteFolder_thenReturnsNotFound() { + // Given: 존재하지 않는 폴더 ID + val nonExistentFolderId = 99999L + + // When & Then: 존재하지 않는 폴더 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/folders/$nonExistentFolderId") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("다른 사용자의 폴더 삭제 시 400 에러를 반환한다") + fun givenOtherUserFolder_whenDeleteFolder_thenReturnsNotFound() { + // Given: 다른 사용자 생성 및 해당 사용자의 폴더 생성 + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + val otherUserFolder = folderRepository.save( + Folder(userId = otherUser.id!!, name = "다른 사용자 폴더"), + ) + + // When & Then: 다른 사용자의 폴더 삭제 시도 (존재하지 않는 것처럼 처리) + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .delete("/api/folders/${otherUserFolder.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFoldersE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFoldersE2ETest.kt new file mode 100644 index 0000000..903b679 --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/DeleteFoldersE2ETest.kt @@ -0,0 +1,156 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.photo.api.dto.DeleteFoldersRequest +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : DeleteFoldersE2ETest + * author : koo + * date : 2025. 12. 29. + * description : 폴더 선택 삭제 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class DeleteFoldersE2ETest : FolderE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // Given: 테스트용 사용자 생성 및 토큰 발급 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("여러 개의 폴더를 선택하여 삭제 시 성공 응답을 반환한다") + fun givenMultipleFolders_whenDeleteFolders_thenReturnsSuccess() { + // Given: 여러 폴더 생성 + val folders = folderRepository.saveAll( + listOf( + Folder(userId = testUser.id!!, name = "폴더1"), + Folder(userId = testUser.id!!, name = "폴더2"), + Folder(userId = testUser.id!!, name = "폴더3"), + ), + ) + val folderIds = folders.map { it.id!! } + + // When & Then: 여러 폴더 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeleteFoldersRequest(folderIds = folderIds)) + .`when`() + .delete("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("단일 폴더만 선택하여 삭제해도 성공한다") + fun givenSingleFolder_whenDeleteFolders_thenReturnsSuccess() { + // Given: 단일 폴더 생성 + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "단일 폴더"), + ) + + // When & Then: 단일 폴더 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeleteFoldersRequest(folderIds = listOf(folder.id!!))) + .`when`() + .delete("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("빈 폴더 ID 리스트로 삭제 요청 시 400 에러를 반환한다") + fun givenEmptyFolderIds_whenDeleteFolders_thenReturnsBadRequest() { + // When & Then: 빈 리스트로 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeleteFoldersRequest(folderIds = emptyList())) + .`when`() + .delete("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("존재하지 않는 폴더 ID가 포함된 경우 400 에러를 반환한다") + fun givenNonExistentFolderId_whenDeleteFolders_thenReturnsNotFound() { + // Given: 실제 폴더와 존재하지 않는 폴더 ID + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "실제 폴더"), + ) + val nonExistentId = 99999L + val folderIds = listOf(folder.id!!, nonExistentId) + + // When & Then: 존재하지 않는 폴더 ID가 포함된 삭제 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeleteFoldersRequest(folderIds = folderIds)) + .`when`() + .delete("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("다른 사용자의 폴더 ID가 포함된 경우 400 에러를 반환한다") + fun givenOtherUserFolderId_whenDeleteFolders_thenReturnsNotFound() { + // Given: 현재 사용자의 폴더와 다른 사용자의 폴더 + val myFolder = folderRepository.save( + Folder(userId = testUser.id!!, name = "내 폴더"), + ) + val (otherUser, _) = createTestUserAndToken(email = "other@example.com") + val otherUserFolder = folderRepository.save( + Folder(userId = otherUser.id!!, name = "다른 사용자 폴더"), + ) + val folderIds = listOf(myFolder.id!!, otherUserFolder.id!!) + + // When & Then: 다른 사용자의 폴더 ID가 포함된 삭제 요청 (존재하지 않는 것처럼 처리) + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(DeleteFoldersRequest(folderIds = folderIds)) + .`when`() + .delete("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/FolderE2ETestBase.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/FolderE2ETestBase.kt new file mode 100644 index 0000000..3ec657e --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/FolderE2ETestBase.kt @@ -0,0 +1,24 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository +import org.junit.jupiter.api.AfterEach +import org.springframework.beans.factory.annotation.Autowired + +/** + * fileName : FolderE2ETestBase + * author : koo + * date : 2025. 12. 29. 오전 2:43 + * description : + */ +abstract class FolderE2ETestBase : E2ETestBase() { + + @Autowired + protected lateinit var folderRepository: JpaFolderRepository + + @AfterEach + override fun tearDown() { + folderRepository.deleteAllInBatch() + super.tearDown() + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt new file mode 100644 index 0000000..1970f2c --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt @@ -0,0 +1,121 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : GetAllFolderE2ETest + * author : koo + * date : 2025. 12. 28. 오후 11:16 + * description : 폴더 목록 조회 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class GetAllFolderE2ETest : FolderE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @BeforeEach + fun setUp() { + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // 테스트용 사용자 생성 및 토큰 발급 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + private fun createFolders(userId: Long): List = folderRepository.saveAll( + listOf( + Folder(userId = userId, name = "폴더1"), + Folder(userId = userId, name = "폴더2"), + Folder(userId = userId, name = "폴더3"), + Folder(userId = userId, name = "폴더4"), + ), + ) + + private fun createSingleFolder(userId: Long): Folder = + folderRepository.save(Folder(userId = userId, name = "단일 폴더")) + + @Test + @DisplayName("폴더가 없을 때 빈 목록을 반환한다") + fun givenNoFolders_whenGetAllFolders_thenReturnsEmptyList() { + // Given: 폴더가 없는 상태 + + // When: 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: 성공 응답 및 빈 목록 검증 + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.success).isTrue() + } + + @Test + @DisplayName("폴더가 있을 때 모든 폴더 목록을 반환한다") + fun givenExistingFolders_whenGetAllFolders_thenReturnsAllFolders() { + // Given: 복수의 폴더 생성 + createFolders(testUser.id!!) + + // When: 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: 성공 응답 및 생성된 폴더 개수 검증 + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.success).isTrue() + } + + @Test + @DisplayName("단일 폴더만 있을 때 해당 폴더를 반환한다") + fun givenSingleFolder_whenGetAllFolders_thenReturnsSingleFolder() { + // Given: 1개의 폴더 생성 + val folderName = "테스트 폴더" + createSingleFolder(testUser.id!!) + + // When: 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: 성공 응답 검증 + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.success).isTrue() + } +} diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt new file mode 100644 index 0000000..1d0680d --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt @@ -0,0 +1,229 @@ +package com.yapp2app.e2e.photo.folder + +import com.yapp2app.common.api.dto.BaseResponse +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.photo.api.dto.CreateFolderRequest +import com.yapp2app.photo.api.dto.CreateFolderResponse +import com.yapp2app.photo.api.dto.UpdateFolderRequest +import com.yapp2app.photo.domain.entity.Folder +import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository +import com.yapp2app.user.domain.entity.User +import io.restassured.RestAssured +import io.restassured.common.mapper.TypeRef +import io.restassured.http.ContentType +import org.hamcrest.CoreMatchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpStatus +import org.springframework.test.context.ActiveProfiles + +/** + * fileName : UpdateFolderE2ETest + * author : koo + * date : 2025. 12. 29. + * description : 폴더 갱신 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UpdateFolderE2ETest : E2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + private lateinit var accessToken: String + private lateinit var testUser: User + + @Autowired + private lateinit var folderRepository: JpaFolderRepository + + @BeforeEach + fun setUp() { + // 테스트 시작 전 데이터 정리 (자식 → 부모 순서) + folderRepository.deleteAllInBatch() + userRepository.deleteAllInBatch() + + RestAssured.port = port + RestAssured.baseURI = "http://localhost" + + // Given: 테스트용 사용자 생성 및 토큰 발급 + val (user, token) = createTestUserAndToken() + testUser = user + accessToken = token + } + + @Test + @DisplayName("유효한 폴더명으로 폴더 갱신 시 성공 응답을 반환한다") + fun givenValidFolderName_whenUpdateFolder_thenReturnsSuccess() { + // Given: 폴더 생성 + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "원래 이름"), + ) + + // When & Then: 폴더명 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "변경된 이름")) + .`when`() + .patch("/api/folders/${folder.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + @Test + @DisplayName("빈 폴더명으로 갱신 시 400 에러를 반환한다") + fun givenBlankFolderName_whenUpdateFolder_thenReturnsBadRequest() { + // Given: 폴더 생성 + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "원래 이름"), + ) + + // When & Then: 빈 폴더명으로 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "")) + .`when`() + .patch("/api/folders/${folder.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("폴더명이 null인 경우 400 에러를 반환한다") + fun givenNullFolderName_whenUpdateFolder_thenReturnsBadRequest() { + // Given: 폴더 생성 + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "원래 이름"), + ) + + val requestBody = """ + { + "name": null + } + """.trimIndent() + + // When & Then: null 폴더명으로 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(requestBody) + .`when`() + .patch("/api/folders/${folder.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("존재하지 않는 폴더 갱신 시 400 에러를 반환한다") + fun givenNonExistentFolder_whenUpdateFolder_thenReturnsNotFound() { + // Given: 존재하지 않는 폴더 ID + val nonExistentFolderId = 99999L + + // When & Then: 존재하지 않는 폴더 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "새 이름")) + .`when`() + .patch("/api/folders/$nonExistentFolderId") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("이미 존재하는 폴더명으로 변경 시 Conflict 에러를 반환한다") + fun givenDuplicateFolderName_whenUpdateFolder_thenReturnsConflict() { + // Given: 두 개의 폴더 생성 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest("폴더1")) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .`as`(object : TypeRef>() {}) + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest("폴더2")) + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.OK.value()) + + println(response) + + // When & Then: 이미 존재하는 폴더명으로 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "폴더2")) + .`when`() + .patch("/api/folders/${response.data?.folderId}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.CONFLICT_FOLDER.code)) + } + + @Test + @DisplayName("다른 사용자의 폴더 갱신 시 400 에러를 반환한다") + fun givenOtherUserFolder_whenUpdateFolder_thenReturnsNotFound() { + // Given: 다른 사용자 생성 및 해당 사용자의 폴더 생성 + val (otherUser, _) = createTestUserAndToken() // 기본값으로 unique한 이메일 생성 + val otherUserFolder = folderRepository.save( + Folder(userId = otherUser.id!!, name = "다른 사용자 폴더"), + ) + + // When & Then: 다른 사용자의 폴더 갱신 시도 (존재하지 않는 것처럼 처리) + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "새 이름")) + .`when`() + .patch("/api/folders/${otherUserFolder.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("success", equalTo(false)) + .body("resultCode", equalTo(ResultCode.NOT_FOUND.code)) + } + + @Test + @DisplayName("동일한 폴더명으로 갱신하면 성공한다") + fun givenSameFolderName_whenUpdateFolder_thenReturnsSuccess() { + // Given: 폴더 생성 + val originalName = "동일한 이름" + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = originalName), + ) + + // When & Then: 동일한 폴더명으로 갱신 요청 + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = originalName)) + .`when`() + .patch("/api/folders/${folder.id}") + .then() + .statusCode(HttpStatus.OK.value()) + .body("success", equalTo(true)) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6b1594a..6474e85 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -16,11 +16,7 @@ spring: flyway: enabled: false -jasypt: - encryptor: - password: ${JASYPT_PASSWORD} - app: auth: - tokenSecret: ENC(LCqGofuONtTzkgq2ly/BDrRSoZT/cwZILTQZSXJtYf2ui5sDDdINUw+CWzb+GYmnk/yZ2RcSD9dvI1V5wMVjVyM3cwAEmX84CWqvvo602W3CLmfCnFnMIRjV2vvWiDJosuKMb7o7FJT/bPx0kuG2KOYhovDFYdgOMHxLtefhrWE=) + tokenSecret: testSecretTokenMustBeVeryLongTestSecretDoesNotUseJasypt tokenExpiry: 7776000000