diff --git a/build.gradle.kts b/build.gradle.kts index cf960f7f..edc13452 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,7 +30,7 @@ val mockkVersion = "1.13.10" val ktlintVersion = "1.5.0" group = "com.yapp2app" -version = "0.0.1" +version = "1.0.0" java { toolchain { 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 6b4f571b..5d9c58a4 100644 --- a/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt +++ b/src/main/kotlin/com/yapp2app/common/exception/handler/ExceptionHandler.kt @@ -15,6 +15,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.context.request.WebRequest +import org.springframework.web.method.annotation.HandlerMethodValidationException import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import java.util.function.Consumer @@ -114,6 +115,16 @@ class ExceptionHandler { HttpStatus.BAD_REQUEST, ) + @ExceptionHandler(HandlerMethodValidationException::class) + fun handleMethodValidationExceptionHandler(ex: HandlerMethodValidationException): ResponseEntity = + ResponseEntity( + ExceptionMsg( + resultCode = ResultCode.INVALID_PARAMETER.code, + message = ResultCode.INVALID_PARAMETER.message, + ), + HttpStatus.BAD_REQUEST, + ) + @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun handleTypeMismatchHandler(ex: MethodArgumentTypeMismatchException): ResponseEntity = if (ex.requiredType?.isEnum == true) { diff --git a/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt b/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt index 627dce1d..87fd7f8d 100644 --- a/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt +++ b/src/main/kotlin/com/yapp2app/photo/api/controller/FolderController.kt @@ -18,6 +18,7 @@ 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 jakarta.validation.constraints.Min import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -72,8 +73,11 @@ class FolderController( description = "폴더 목록을 조회합니다.", ) @GetMapping - fun getAllFolder(@AuthenticationPrincipal(expression = "id") userId: Long): BaseResponse { - val command = commandConverter.toGetFoldersCommand(userId) + fun getAllFolder( + @AuthenticationPrincipal(expression = "id") userId: Long, + @RequestParam("limit") @Min(1) limit: Int?, + ): BaseResponse { + val command = commandConverter.toGetFoldersCommand(userId, limit) val result = getFoldersUseCase.execute(command) diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt index 8e97df74..5649fe87 100644 --- a/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/FolderCommandConverter.kt @@ -23,7 +23,7 @@ class FolderCommandConverter { fun toCreateFolderCommand(request: CreateFolderRequest, userId: Long): CreateFolderCommand = CreateFolderCommand(userId, request.name!!) - fun toGetFoldersCommand(userId: Long): GetFoldersCommand = GetFoldersCommand(userId) + fun toGetFoldersCommand(userId: Long, limit: Int?): GetFoldersCommand = GetFoldersCommand(userId, limit) fun toDeleteFoldersCommand(request: DeleteFoldersRequest, userId: Long, deletePhotos: Boolean) = DeleteFoldersCommand(userId, request.folderIds, deletePhotos) diff --git a/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt index 2bb12afe..2fe82d05 100644 --- a/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt +++ b/src/main/kotlin/com/yapp2app/photo/api/converter/PhotoImageCommandConverter.kt @@ -29,6 +29,7 @@ class PhotoImageCommandConverter { memo = item.memo, ) }, + favorite = request.favorite ?: false, ) fun toGetPhotosCommand( diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt index 00f7af23..186b9852 100644 --- a/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/FolderRequest.kt @@ -14,7 +14,7 @@ import jakarta.validation.constraints.Size data class CreateFolderRequest( @field:Schema(description = "폴더명", example = "즐겨찾기") @field:NotBlank(message = "폴더명은 필수입니다.") - @field:Size(min = 1, max = 16, message = "폴더명은 1 ~ 16자 사이여야 합니다.") + @field:Size(min = 1, max = 10, message = "폴더명은 1자 이상 10자 이하여야 합니다.") val name: String?, ) @@ -30,7 +30,7 @@ data class DeleteFoldersRequest( data class UpdateFolderRequest( @field:Schema(description = "변경할 폴더명", example = "대학교 친구") @field:NotBlank(message = "폴더명은 필수입니다.") - @field:Size(min = 1, max = 16, message = "폴더명은 1 ~ 16자 사이여야 합니다.") + @field:Size(min = 1, max = 10, message = "폴더명은 1자 이상 10자 이하여야 합니다.") val name: String?, ) diff --git a/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt index 1e9c4eae..0dd23b38 100644 --- a/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt +++ b/src/main/kotlin/com/yapp2app/photo/api/dto/PhotoImageRequest.kt @@ -5,6 +5,7 @@ import jakarta.annotation.Nullable import jakarta.validation.Valid import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size /** * fileName : PhotoImageRequest @@ -18,8 +19,11 @@ data class UploadPhotoRequest( @field:NotEmpty(message = "uploads가 비어있습니다.") @field:Valid - @field:jakarta.validation.constraints.Size(max = 10, message = "한 번에 최대 10장까지 업로드할 수 있습니다.") + @field:Size(max = 10, message = "한 번에 최대 10장까지 업로드할 수 있습니다.") val uploads: List, + + @field:Schema(description = "업로드 사진 즐겨찾기 등록 여부", example = "true") + val favorite: Boolean? = null, ) { data class UploadPhotoItem( @field:NotNull(message = "mediaId는 필수 입력값입니다.") diff --git a/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt b/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt index be06d595..d6e2b5ba 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/command/FolderCommand.kt @@ -10,7 +10,7 @@ data class CreateFolderCommand(val userId: Long, val name: String) data class DeleteFoldersCommand(val userId: Long, val folderIds: List, val deletePhotos: Boolean = false) -data class GetFoldersCommand(val userId: Long) +data class GetFoldersCommand(val userId: Long, val limit: Int?) data class UpdateFolderCommand(val userId: Long, val folderId: Long, val newName: String) diff --git a/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt b/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt index 55b7f490..2c29962b 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/command/PhotoImageCommand.kt @@ -8,7 +8,12 @@ import com.yapp2app.common.domain.vo.SortOrder * date : 2026. 1. 2. 오후 8:28 * description : Photo image domain command */ -data class UploadPhotoCommand(val userId: Long, val folderId: Long?, val uploads: List) { +data class UploadPhotoCommand( + val userId: Long, + val folderId: Long?, + val uploads: List, + val favorite: Boolean, +) { data class UploadItem(val mediaId: Long, val memo: String?) } diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/FavoriteImageRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/FavoriteImageRepositoryPort.kt index 2a2c49c0..8069bfc8 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/port/FavoriteImageRepositoryPort.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/port/FavoriteImageRepositoryPort.kt @@ -10,6 +10,8 @@ interface FavoriteImageRepositoryPort { fun add(userId: Long, photoId: Long) + fun addAll(userId: Long, photoIds: List) + fun delete(userId: Long, photoId: Long) fun deleteAll(userId: Long, photoIds: List) diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt index f47d8789..920805b0 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/port/FolderRepositoryPort.kt @@ -17,7 +17,7 @@ interface FolderRepositoryPort { fun listOwnedFolders(userId: Long): List - fun listOwnedFoldersWithStats(userId: Long): List + fun listOwnedFoldersWithStats(userId: Long, limit: Int?): List fun getOwnedFolder(userId: Long, folderId: Long): Folder? fun getOwnedFolders(userId: Long, folderIds: List): List diff --git a/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt b/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt index 1af78ea4..fa5ec8af 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/port/PhotoImageRepositoryPort.kt @@ -19,6 +19,8 @@ interface PhotoImageRepositoryPort { fun saveAll(photoImages: List): List + fun getRegisteredMediaIds(mediaIds: List): Set + /** * 조회 */ diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt index ced78d16..0f5bd524 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/GetFoldersUseCase.kt @@ -17,7 +17,7 @@ class GetFoldersUseCase(private val folderRepository: FolderRepositoryPort) { @Transactional(readOnly = true) fun execute(command: GetFoldersCommand): GetFoldersResult { - val foldersWithStats = folderRepository.listOwnedFoldersWithStats(command.userId) + val foldersWithStats = folderRepository.listOwnedFoldersWithStats(command.userId, command.limit) val items = foldersWithStats.map { folder -> GetFoldersResult.FolderInfo( diff --git a/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotosUseCase.kt b/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotosUseCase.kt index df3d6d1c..cc0e155e 100644 --- a/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotosUseCase.kt +++ b/src/main/kotlin/com/yapp2app/photo/application/usecase/UploadPhotosUseCase.kt @@ -6,13 +6,14 @@ import com.yapp2app.common.exception.BusinessException import com.yapp2app.common.transaction.TransactionRunner import com.yapp2app.photo.application.command.UploadPhotoCommand import com.yapp2app.photo.application.contract.MediaAvailability +import com.yapp2app.photo.application.port.FavoriteImageRepositoryPort import com.yapp2app.photo.application.port.FolderRepositoryPort import com.yapp2app.photo.application.port.MediaClientPort import com.yapp2app.photo.application.port.PhotoImageRepositoryPort import com.yapp2app.photo.domain.entity.PhotoImage /** - * fileName : BulkUploadPhotoUseCase + * fileName : UploadPhotosUseCase * author : koo * date : 2026. 1. 20. * description : 다중 사진 업로드 UseCase (최대 10장) @@ -22,6 +23,7 @@ class UploadPhotosUseCase( private val mediaClient: MediaClientPort, private val photoImageRepository: PhotoImageRepositoryPort, private val folderRepository: FolderRepositoryPort, + private val favoriteImageRepository: FavoriteImageRepositoryPort, private val transactionRunner: TransactionRunner, ) { @@ -30,17 +32,18 @@ class UploadPhotosUseCase( validateNoDuplicateMediaIds(command.uploads) validateFolderOwnership(command.userId, command.folderId) - val mediaIds = command.uploads.map { it.mediaId } + val newUploads = filterNewUploads(command.uploads) + if (newUploads.isEmpty()) return + + val newMediaIds = newUploads.map { it.mediaId } - // 모든 media가 object storage에 정상적으로 저장되었는지 일괄 확인 val availabilities = mediaClient.verifyMediasUploaded( ownerId = command.userId, - mediaIds = mediaIds, + mediaIds = newMediaIds, ) - rollbackIfFailed(command.userId, availabilities) - val photos = command.uploads.map { upload -> + val photos = newUploads.map { upload -> PhotoImage( userId = command.userId, mediaId = upload.mediaId, @@ -51,15 +54,27 @@ class UploadPhotosUseCase( try { transactionRunner.run { - photoImageRepository.saveAll(photos) + val savedPhotos = photoImageRepository.saveAll(photos) + if (command.favorite) { + favoriteImageRepository.addAll(command.userId, savedPhotos.map { it.id!! }) + } } + } catch (e: BusinessException) { + if (e.resultCode == ResultCode.ALREADY_REQUEST) return + mediaClient.rollbackMediasUploaded(command.userId, newMediaIds) + throw e } catch (e: Exception) { - // 보상 트랜잭션: 모든 media 상태를 INITIATED로 롤백 - mediaClient.rollbackMediasUploaded(command.userId, mediaIds) + mediaClient.rollbackMediasUploaded(command.userId, newMediaIds) throw e } } + private fun filterNewUploads(uploads: List): List { + val mediaIds = uploads.map { it.mediaId } + val existingMediaIds = photoImageRepository.getRegisteredMediaIds(mediaIds) + return uploads.filter { it.mediaId !in existingMediaIds } + } + private fun validateNoDuplicateMediaIds(uploads: List) { val mediaIds = uploads.map { it.mediaId } val duplicates = mediaIds.groupingBy { it }.eachCount().filter { it.value > 1 }.keys diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/FavoriteImageRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/FavoriteImageRepositoryAdapter.kt index 2f009b47..1916bcff 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/FavoriteImageRepositoryAdapter.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/FavoriteImageRepositoryAdapter.kt @@ -27,6 +27,12 @@ class FavoriteImageRepositoryAdapter( } } + override fun addAll(userId: Long, photoIds: List) { + if (photoIds.isEmpty()) return + val favorites = photoIds.map { photoId -> FavoritePhoto(FavoritePhotoId(userId, photoId)) } + jpaRepository.saveAll(favorites) + } + override fun delete(userId: Long, photoId: Long) = jpaRepository.deleteById( FavoritePhotoId(userId, photoId), ) diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt index 7c83bfcd..0c64a420 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/FolderRepositoryAdapter.kt @@ -26,8 +26,8 @@ class FolderRepositoryAdapter( override fun listOwnedFolders(userId: Long): List = jpaRepository.findAllByUserId(userId) - override fun listOwnedFoldersWithStats(userId: Long): List = - queryRepository.findOwnedFoldersWithStats(userId) + override fun listOwnedFoldersWithStats(userId: Long, limit: Int?): List = + queryRepository.findOwnedFoldersWithStats(userId, limit) override fun getOwnedFolders(userId: Long, folderIds: List): List = jpaRepository.findAllByUserIdAndIdIn(userId, folderIds) diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt index 6d866aab..f7aff8d8 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/PhotoImageRepositoryAdapter.kt @@ -1,11 +1,14 @@ package com.yapp2app.photo.infra.persist +import com.yapp2app.common.api.dto.ResultCode import com.yapp2app.common.domain.vo.SortOrder +import com.yapp2app.common.exception.BusinessException import com.yapp2app.photo.application.contract.PhotoWithFavorite import com.yapp2app.photo.application.port.PhotoImageRepositoryPort import com.yapp2app.photo.domain.entity.PhotoImage import com.yapp2app.photo.infra.persist.jpa.JpaPhotoImageRepository import com.yapp2app.photo.infra.persist.jpa.PhotoImageQueryRepository +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Repository /** @@ -24,7 +27,14 @@ class PhotoImageRepositoryAdapter( queryRepository.findOwnedPhotoWithFavorite(userId, photoId) override fun save(photoImage: PhotoImage): PhotoImage = jpaRepository.save(photoImage) - override fun saveAll(photoImages: List): List = jpaRepository.saveAll(photoImages) + override fun saveAll(photoImages: List): List = try { + jpaRepository.saveAll(photoImages) + } catch (e: DataIntegrityViolationException) { + throw BusinessException(ResultCode.ALREADY_REQUEST) + } + + override fun getRegisteredMediaIds(mediaIds: List): Set = + queryRepository.getRegisteredMediaIds(mediaIds) override fun listOwnedPhotos(userId: Long, offset: Int, limit: Int, sortOrder: SortOrder): List = queryRepository.findOwnedPhotos(userId, offset, limit, sortOrder) diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt index 0681d73f..afc18203 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/FolderQueryRepository.kt @@ -27,9 +27,11 @@ class FolderQueryRepository(private val queryFactory: JPAQueryFactory) { .execute().toInt() } - fun findOwnedFoldersWithStats(userId: Long): List { + fun findOwnedFoldersWithStats(userId: Long, limit: Int?): List { + val latestPhotoDate = photoImage.createdAt.max() + val folderStats = queryFactory - .select(folder.id, folder.name, photoImage.id.count()) + .select(folder.id, folder.name, photoImage.id.count(), latestPhotoDate) .from(folder) .leftJoin(photoImage).on( photoImage.folderId.eq(folder.id), @@ -37,6 +39,8 @@ class FolderQueryRepository(private val queryFactory: JPAQueryFactory) { ) .where(folder.userId.eq(userId)) .groupBy(folder.id, folder.name) + .orderBy(latestPhotoDate.desc().nullsLast(), folder.createdAt.desc()) + .apply { limit?.let { limit(it.toLong()) } } .fetch() if (folderStats.isEmpty()) return emptyList() diff --git a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt index c42ba293..5119fc7a 100644 --- a/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt +++ b/src/main/kotlin/com/yapp2app/photo/infra/persist/jpa/PhotoImageQueryRepository.kt @@ -148,6 +148,17 @@ class PhotoImageQueryRepository(private val queryFactory: JPAQueryFactory) { .fetch() } + fun getRegisteredMediaIds(mediaIds: List): Set { + if (mediaIds.isEmpty()) return emptySet() + + return queryFactory + .select(photoImage.mediaId) + .from(photoImage) + .where(photoImage.mediaId.`in`(mediaIds)) + .fetch() + .toSet() + } + fun removePhotosFromFolder(userId: Long, folderId: Long, photoIds: List): Int { if (photoIds.isEmpty()) return 0 diff --git a/src/main/resources/application-staging.yaml b/src/main/resources/application-staging.yaml index 670b183c..aa28a4d3 100644 --- a/src/main/resources/application-staging.yaml +++ b/src/main/resources/application-staging.yaml @@ -43,7 +43,7 @@ app: oauth: kakao: - androidClientId: ENC(Qzg5YG7q+laisg0oYQi100q1q3RRCBR8gkHtAEcgEPkmNj7Jmz+58693hruGEova3zfVL6C5N6oKR8tVHRa7fysnNTSkTvAcx3uxTcUZhR4=) + androidClientId: ENC(lDI6cw226mDsd0R8PMdfQZdksG0cl+UN05E4+hJI7KguRGIn1+oBB/mLDhkXfp+43Oq7gkQCo7vKLrZMxzFDLQhbeb7Mx8KMEyBCjw7LfSQ=) iosClientId: ENC(0KhQuAcBG2ddDzIS/WhvDbb+l+SbuCiYE/ANF8OVMvtvQcEwsY8xiFlr5lp0IAKyLEk75SSoIhM5picVY7xjV0+DPl/vA6lt8CA4Ae17Hd8=) clientSecret: ENC(PjnJy0hcYdqBkqvSGqCcEp61czlJpJ1n4nYMCkAcFdo7ASIJrbBUqxJay41zVg8kG45KiPhYbxTfJe+X9vQlp5qZfIbZuVtAc85SHLexQIc=) jwksUri: https://kauth.kakao.com/.well-known/jwks.json diff --git a/src/main/resources/db/migration/V10__add_unique_constraint_media_id_to_photo_image.sql b/src/main/resources/db/migration/V10__add_unique_constraint_media_id_to_photo_image.sql new file mode 100644 index 00000000..4da61c96 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_unique_constraint_media_id_to_photo_image.sql @@ -0,0 +1,2 @@ +ALTER TABLE TB_PHOTO_IMAGE + ADD CONSTRAINT uk_photo_image_media_id UNIQUE (media_id); diff --git a/src/main/resources/db/migration/V11__alter_folder_name_length.sql b/src/main/resources/db/migration/V11__alter_folder_name_length.sql new file mode 100644 index 00000000..e735dbe1 --- /dev/null +++ b/src/main/resources/db/migration/V11__alter_folder_name_length.sql @@ -0,0 +1,5 @@ +-- 기존 데이터 중 10자 초과 폴더명을 10자로 잘라냄 +UPDATE TB_FOLDER SET name = LEFT(name, 10) WHERE LENGTH(name) > 10; + +-- 폴더명 최대 길이를 10자로 제한 +ALTER TABLE TB_FOLDER ALTER COLUMN name TYPE VARCHAR(10); diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt index 6857c36a..7b766507 100644 --- a/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/CreateFolderE2ETest.kt @@ -134,4 +134,18 @@ class CreateFolderE2ETest : FolderE2ETestBase() { .statusCode(HttpStatus.BAD_REQUEST.value()) .body("resultCode", equalTo(ResultCode.CONFLICT_FOLDER.code)) } + + @Test + @DisplayName("폴더명이 10자를 초과하면 400 에러를 반환한다") + fun givenTooLongFolderName_whenCreateFolder_thenReturnsBadRequest() { + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(CreateFolderRequest(name = "일이삼사오육칠팔구십일")) // 11자 + .`when`() + .post("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.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 index 3ec657e2..ec8ce57c 100644 --- a/src/test/kotlin/com/yapp2app/e2e/photo/folder/FolderE2ETestBase.kt +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/FolderE2ETestBase.kt @@ -1,9 +1,16 @@ package com.yapp2app.e2e.photo.folder import com.yapp2app.e2e.E2ETestBase +import com.yapp2app.media.domain.MediaType +import com.yapp2app.media.domain.entity.Media +import com.yapp2app.media.domain.entity.MediaStatus +import com.yapp2app.media.infra.persist.jpa.JpaMediaRepository +import com.yapp2app.photo.domain.entity.PhotoImage import com.yapp2app.photo.infra.persist.jpa.JpaFolderRepository +import com.yapp2app.photo.infra.persist.jpa.JpaPhotoImageRepository import org.junit.jupiter.api.AfterEach import org.springframework.beans.factory.annotation.Autowired +import java.util.UUID /** * fileName : FolderE2ETestBase @@ -16,9 +23,36 @@ abstract class FolderE2ETestBase : E2ETestBase() { @Autowired protected lateinit var folderRepository: JpaFolderRepository + @Autowired + protected lateinit var photoImageRepository: JpaPhotoImageRepository + + @Autowired + protected lateinit var mediaRepository: JpaMediaRepository + @AfterEach override fun tearDown() { + photoImageRepository.deleteAllInBatch() folderRepository.deleteAllInBatch() + mediaRepository.deleteAllInBatch() super.tearDown() } + + protected fun createMedia(ownerId: Long): Media = mediaRepository.save( + Media( + storageKey = "test-storage-key-${UUID.randomUUID()}", + ownerId = ownerId, + mediaType = MediaType.PHOTO_BOOTH, + status = MediaStatus.UPLOADED, + contentType = "image/jpeg", + ), + ) + + protected fun createPhotoImage(userId: Long, mediaId: Long, folderId: Long? = null): PhotoImage = + photoImageRepository.save( + PhotoImage( + userId = userId, + mediaId = mediaId, + folderId = folderId, + ), + ) } diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt index 6a0c754a..b5f32250 100644 --- a/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/GetAllFolderE2ETest.kt @@ -7,6 +7,7 @@ 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.hamcrest.Matchers.equalTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -97,6 +98,128 @@ class GetAllFolderE2ETest : FolderE2ETestBase() { assertThat(baseResponse.resultCode).isEqualTo(ResultCode.SUCCESS.code) } + @Test + @DisplayName("limit 파라미터를 전달하면 해당 개수만큼만 폴더를 반환한다") + fun givenFoldersAndLimit_whenGetAllFolders_thenReturnsLimitedFolders() { + // Given: 4개의 폴더 생성 + createFolders(testUser.id!!) + + // When: limit=3으로 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .queryParam("limit", 3) + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: 성공 응답 및 3개만 반환 검증 + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.resultCode).isEqualTo(ResultCode.SUCCESS.code) + + val data = baseResponse.data as Map<*, *> + val items = data["items"] as List<*> + assertThat(items).hasSize(3) + } + + @Test + @DisplayName("limit 파라미터가 없으면 모든 폴더를 반환한다") + fun givenFoldersAndNoLimit_whenGetAllFolders_thenReturnsAllFolders() { + // Given: 4개의 폴더 생성 + createFolders(testUser.id!!) + + // When: limit 없이 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: 성공 응답 및 전체 4개 반환 검증 + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.resultCode).isEqualTo(ResultCode.SUCCESS.code) + + val data = baseResponse.data as Map<*, *> + val items = data["items"] as List<*> + assertThat(items).hasSize(4) + } + + @Test + @DisplayName("최근에 사진이 추가된 폴더가 먼저 노출된다") + fun givenFoldersWithPhotos_whenGetAllFolders_thenReturnsSortedByLatestPhoto() { + // Given: 폴더 A, B, C 생성 + val folderA = folderRepository.save(Folder(userId = testUser.id!!, name = "폴더A")) + val folderB = folderRepository.save(Folder(userId = testUser.id!!, name = "폴더B")) + val folderC = folderRepository.save(Folder(userId = testUser.id!!, name = "폴더C")) + + // C에 사진 추가 + val mediaC = createMedia(testUser.id!!) + createPhotoImage(testUser.id!!, mediaC.id!!, folderC.id) + + // B에 사진 추가 (C보다 나중) + val mediaB = createMedia(testUser.id!!) + createPhotoImage(testUser.id!!, mediaB.id!!, folderB.id) + + // When: 폴더 목록 조회 API 호출 + val response = RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .`when`() + .get("/api/folders") + .then() + .extract() + + // Then: B, C, A 순서로 반환 (사진 없는 A는 맨 뒤) + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()) + + val baseResponse = response.`as`(BaseResponse::class.java) + assertThat(baseResponse.resultCode).isEqualTo(ResultCode.SUCCESS.code) + + val data = baseResponse.data as Map<*, *> + val items = data["items"] as List<*> + assertThat(items).hasSize(3) + + val folderNames = items.map { (it as Map<*, *>)["name"] as String } + assertThat(folderNames).containsExactly("폴더B", "폴더C", "폴더A") + } + + @Test + @DisplayName("limit=0 전달 시 400 Bad Request를 반환한다") + fun givenZeroLimit_whenGetAllFolders_thenReturnsBadRequest() { + // Given & When & Then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .queryParam("limit", 0) + .`when`() + .get("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + + @Test + @DisplayName("limit=-1 전달 시 400 Bad Request를 반환한다") + fun givenNegativeLimit_whenGetAllFolders_thenReturnsBadRequest() { + // Given & When & Then + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .queryParam("limit", -1) + .`when`() + .get("/api/folders") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } + @Test @DisplayName("단일 폴더만 있을 때 해당 폴더를 반환한다") fun givenSingleFolder_whenGetAllFolders_thenReturnsSingleFolder() { diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt index fadaa9c7..4621743d 100644 --- a/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt +++ b/src/test/kotlin/com/yapp2app/e2e/photo/folder/UpdateFolderE2ETest.kt @@ -219,4 +219,22 @@ class UpdateFolderE2ETest : E2ETestBase() { .statusCode(HttpStatus.OK.value()) .body("resultCode", equalTo(ResultCode.SUCCESS.code)) } + + @Test + @DisplayName("폴더명이 10자를 초과하면 400 에러를 반환한다") + fun givenTooLongFolderName_whenUpdateFolder_thenReturnsBadRequest() { + val folder = folderRepository.save( + Folder(userId = testUser.id!!, name = "원래 이름"), + ) + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(UpdateFolderRequest(name = "일이삼사오육칠팔구십일")) // 11자 + .`when`() + .patch("/api/folders/${folder.id}") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .body("resultCode", equalTo(ResultCode.INVALID_PARAMETER.code)) + } } diff --git a/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotosIdempotencyE2ETest.kt b/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotosIdempotencyE2ETest.kt new file mode 100644 index 00000000..982771ab --- /dev/null +++ b/src/test/kotlin/com/yapp2app/e2e/photo/image/UploadPhotosIdempotencyE2ETest.kt @@ -0,0 +1,196 @@ +package com.yapp2app.e2e.photo.image + +import com.yapp2app.common.api.dto.ResultCode +import com.yapp2app.media.api.dto.UploadTicketRequest +import com.yapp2app.media.domain.MediaType +import com.yapp2app.photo.api.dto.UploadPhotoRequest +import com.yapp2app.photo.infra.persist.jpa.PhotoImageQueryRepository +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.hamcrest.Matchers.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 : UploadPhotosIdempotencyE2ETest + * author : claude + * date : 2026. 2. 14. + * description : POST /api/photos 멱등성 보장 E2E 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UploadPhotosIdempotencyE2ETest : PhotoImageE2ETestBase() { + + @LocalServerPort + private var port: Int = 0 + + @Autowired + private lateinit var photoImageQueryRepository: PhotoImageQueryRepository + + 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 + } + + // =================== + // Helper Methods + // =================== + + private fun createMediaIds(count: Int): List { + val ticketRequest = UploadTicketRequest( + items = (1..count).map { + UploadTicketRequest.UploadTicketItem( + filename = "photo$it.jpg", + contentType = "image/jpeg", + mediaType = MediaType.PHOTO_BOOTH, + ) + }, + ) + + return RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(ticketRequest) + .`when`() + .post("/api/media/upload") + .then() + .statusCode(HttpStatus.OK.value()) + .extract() + .jsonPath() + .getList("data.items.mediaId") + .map { it.toLong() } + } + + private fun uploadPhotos(folderId: Long?, mediaIds: List) { + val uploadRequest = UploadPhotoRequest( + folderId = folderId, + uploads = mediaIds.map { + UploadPhotoRequest.UploadPhotoItem( + mediaId = it, + memo = null, + ) + }, + ) + + RestAssured.given() + .contentType(ContentType.JSON) + .header("Authorization", "Bearer $accessToken") + .body(uploadRequest) + .`when`() + .post("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("resultCode", equalTo(ResultCode.SUCCESS.code)) + } + + // =================== + // Test Cases + // =================== + + @Test + @DisplayName("동일 mediaIds로 두 번 호출해도 두 번째 호출이 성공하고 레코드가 중복 생성되지 않는다") + fun givenAlreadyUploadedPhotos_whenRetryWithSameMediaIds_thenIdempotentSuccess() { + // given + val mediaIds = createMediaIds(3) + + // 첫 번째 호출 → 정상 저장 + uploadPhotos(null, mediaIds) + val countAfterFirst = photoImageQueryRepository.getRegisteredMediaIds(mediaIds).size + + // when: 동일 mediaIds로 두 번째 호출 + uploadPhotos(null, mediaIds) + val countAfterSecond = photoImageQueryRepository.getRegisteredMediaIds(mediaIds).size + + // then: 레코드 수가 변하지 않아야 함 + assertThat(countAfterFirst).isEqualTo(3) + assertThat(countAfterSecond).isEqualTo(3) + } + + @Test + @DisplayName("폴더 지정 후 동일 mediaIds로 재시도해도 멱등 성공") + fun givenPhotosInFolder_whenRetryWithSameMediaIds_thenIdempotentSuccess() { + // given + val folder = createFolder(testUser.id!!, "테스트 폴더") + val mediaIds = createMediaIds(2) + + // 첫 번째 호출 + uploadPhotos(folder.id, mediaIds) + + // when: 동일 요청 재시도 + uploadPhotos(folder.id, mediaIds) + + // then + assertThat(photoImageQueryRepository.getRegisteredMediaIds(mediaIds)).hasSize(2) + + RestAssured.given() + .header("Authorization", "Bearer $accessToken") + .queryParam("folderId", folder.id) + .`when`() + .get("/api/photos") + .then() + .statusCode(HttpStatus.OK.value()) + .body("data.items.size()", equalTo(2)) + } + + @Test + @DisplayName("부분적으로 저장된 상태에서 전체 mediaIds로 재시도하면 나머지만 저장된다") + fun givenPartialUpload_whenRetryWithAllMediaIds_thenOnlyNewOnesAreSaved() { + // given: mediaId 3개 발급 후 앞 2개만 먼저 업로드 + val mediaIds = createMediaIds(3) + uploadPhotos(null, mediaIds.take(2)) + + val countAfterPartial = photoImageQueryRepository.getRegisteredMediaIds(mediaIds).size + assertThat(countAfterPartial).isEqualTo(2) + + // when: 3개 전부로 재시도 + uploadPhotos(null, mediaIds) + + // then: 3개 레코드가 존재해야 함 + assertThat(photoImageQueryRepository.getRegisteredMediaIds(mediaIds)).hasSize(3) + } + + @Test + @DisplayName("단건 업로드 후 동일 mediaId로 재시도해도 멱등 성공") + fun givenSinglePhoto_whenRetryUpload_thenIdempotentSuccess() { + // given + val mediaIds = createMediaIds(1) + uploadPhotos(null, mediaIds) + + // when + uploadPhotos(null, mediaIds) + + // then + assertThat(photoImageQueryRepository.getRegisteredMediaIds(mediaIds)).hasSize(1) + } + + @Test + @DisplayName("최초 업로드는 정상적으로 저장된다") + fun givenNewMediaIds_whenUpload_thenAllSaved() { + // given + val mediaIds = createMediaIds(3) + + // when + uploadPhotos(null, mediaIds) + + // then + val registeredMediaIds = photoImageQueryRepository.getRegisteredMediaIds(mediaIds) + assertThat(registeredMediaIds).hasSize(3) + assertThat(registeredMediaIds).isEqualTo(mediaIds.toSet()) + } +}