diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt index 6e10344a..bc84c525 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt @@ -161,11 +161,7 @@ class ManageAccountTransactionUseCase( } private fun deleteTransactionReceipts(transactionId: Long) { - val receipts = findTransactionReceipts(transactionId) - if (receipts.isNotEmpty()) { - fileRepository.deleteAll(receipts) - fileRepository.flush() - } + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.ACCOUNT_TRANSACTION, transactionId) } private fun findTransactionReceipts(transactionId: Long): List = diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index cb434657..b168084c 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -18,7 +18,6 @@ import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -30,7 +29,6 @@ class ManagePostUseCase( private val clubMemberPolicy: ClubMemberPolicy, private val clubMemberCardinalReader: ClubMemberCardinalReader, private val fileRepository: FileRepository, - private val fileReader: FileReader, private val fileMapper: FileMapper, private val postMapper: PostMapper, ) { @@ -111,8 +109,7 @@ class ManagePostUseCase( clubId: Long, ): Board = boardRepository.findByIdAndClubIdAndIsDeletedFalse(boardId, clubId) ?: throw BoardNotFoundException() - private fun findPost(postId: Long): Post = - postRepository.findActivePostById(postId) ?: throw PostNotFoundException() + private fun findPost(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() private fun validateOwner( post: Post, @@ -154,11 +151,6 @@ class ManagePostUseCase( } private fun deletePostFiles(postId: Long) { - val files = fileReader.findAll(FileOwnerType.POST, postId) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - fileRepository.flush() - } + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.POST, postId) } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt index 6418d4ad..d2727ca5 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostLikeRepository.kt @@ -37,6 +37,24 @@ interface PostLikeRepository : @Param("clubId") clubId: Long, ): List + @Query( + """ + SELECT p.id + FROM PostLike pl + JOIN pl.post p + JOIN p.board b + WHERE pl.userId = :userId + AND b.club.id IN :clubIds + AND pl.isActive = true + AND pl.deletedAt IS NULL + ORDER BY p.id ASC + """, + ) + fun findActivePostIdsByUserIdAndClubIdIn( + @Param("userId") userId: Long, + @Param("clubIds") clubIds: List, + ): List + @Query( """ SELECT pl diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 4ce48920..da6e9ec1 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -217,4 +217,18 @@ interface PostRepository : fun countActivePostsByBoardIds( @Param("boardIds") boardIds: List, ): List + + @Query( + """ + SELECT p.id + FROM Post p + WHERE p.clubMember.id IN :clubMemberIds + AND p.isDeleted = false + AND p.board.isDeleted = false + ORDER BY p.id ASC + """, + ) + fun findActiveIdsByClubMemberIdIn( + @Param("clubMemberIds") clubMemberIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index e6e5f98c..49f28535 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -22,8 +22,6 @@ import com.weeth.domain.club.domain.service.ClubJoinPolicy import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader @@ -47,7 +45,6 @@ class ManageClubMemberUsecase( private val clubJoinPolicy: ClubJoinPolicy, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, private val fileRepository: FileRepository, - private val fileAccessUrlPort: FileAccessUrlPort, private val clock: Clock, ) { /** @@ -92,19 +89,11 @@ class ManageClubMemberUsecase( userId: Long, request: UpdateMemberProfileRequest, ) { - val members = clubMemberRepository.findActiveByUserId(userId) + val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() request.profileImage?.let { profileImage -> - val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(userId) val file = File.createUploaded( @@ -125,18 +114,10 @@ class ManageClubMemberUsecase( @Transactional fun deleteProfileImage(userId: Long) { - val members = clubMemberRepository.findActiveByUserId(userId) + val members = clubMemberRepository.findAllActiveByUserIdWithLock(userId) if (members.isEmpty()) throw ClubMemberNotFoundException() - val existingFiles = - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - if (existingFiles.isNotEmpty()) { - fileRepository.deleteAll(existingFiles) - } + markFilesDeleted(userId) members.forEach { it.removeProfileImage() } } @@ -186,4 +167,8 @@ class ManageClubMemberUsecase( clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) member.leave(now) } + + private fun markFilesDeleted(userId: Long) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) + } } diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 9c6ace04..78975d53 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -28,7 +28,6 @@ import com.weeth.domain.club.domain.vo.ClubContact import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader @@ -143,7 +142,7 @@ class ManageClubUseCase( ) { clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubRepository.getClubById(clubId) + val club = clubRepository.getClubByIdForUpdate(clubId) if (request.primaryContact == PrimaryContact.EMAIL) { val resolvedEmail = request.contactEmail ?: club.clubContact.email @@ -181,7 +180,7 @@ class ManageClubUseCase( ) { clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubRepository.getClubById(clubId) + val club = clubRepository.getClubByIdForUpdate(clubId) val newCode = ClubCodePolicy.generateCode() club.regenerateCode(newCode) } @@ -193,7 +192,7 @@ class ManageClubUseCase( ) { clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubRepository.getClubById(clubId) + val club = clubRepository.getClubByIdForUpdate(clubId) deleteExistingFiles(FileOwnerType.CLUB_PROFILE, clubId) club.removeProfileImage() } @@ -205,7 +204,7 @@ class ManageClubUseCase( ) { clubPermissionPolicy.requireAdmin(clubId, userId) - val club = clubRepository.getClubById(clubId) + val club = clubRepository.getClubByIdForUpdate(clubId) deleteExistingFiles(FileOwnerType.CLUB_BACKGROUND, clubId) club.removeBackgroundImage() } @@ -239,11 +238,7 @@ class ManageClubUseCase( ownerType: FileOwnerType, ownerId: Long, ) { - val files = fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(ownerType, ownerId, FileStatus.UPLOADED) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - } + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(ownerType, ownerId) } private fun validatePrimaryContactEmail( diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt index 2e2c34a2..f6b086f7 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt @@ -3,6 +3,9 @@ package com.weeth.domain.club.domain.service import com.weeth.domain.board.domain.repository.PostLikeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -19,31 +22,74 @@ import java.time.LocalDateTime class ClubActivityDeletionPolicy( private val postLikeRepository: PostLikeRepository, private val postRepository: PostRepository, + private val commentRepository: CommentRepository, + private val fileRepository: FileRepository, ) { fun markMemberActivitiesDeleted( member: ClubMember, now: LocalDateTime, ) { - val postIds = - postLikeRepository - .findActivePostIdsByUserIdAndClubId( - userId = member.user.id, - clubId = member.club.id, - ).distinct() - .sorted() - - if (postIds.isEmpty()) return - - val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } - if (postsById.isEmpty()) return - - postLikeRepository - .findAllActiveByUserIdAndPostIds( - userId = member.user.id, - postIds = postsById.keys.toList(), - ).forEach { like -> - if (!like.markDeleted(now)) return@forEach - postsById.getValue(like.post.id).decreaseLikeCount() + markMembersActivitiesDeleted(listOf(member), now) + } + + fun markMembersActivitiesDeleted( + members: List, + now: LocalDateTime, + ) { + if (members.isEmpty()) return + + deleteMembersFiles(members) + markMembersPostLikesDeleted(members, now) + } + + private fun deleteMembersFiles(members: List) { + val memberIds = members.map { it.id }.distinct().sorted() + val postIds = postRepository.findActiveIdsByClubMemberIdIn(memberIds) + val commentIds = commentRepository.findActiveIdsByClubMemberIdIn(memberIds) + + deleteFiles(FileOwnerType.POST, postIds) + deleteFiles(FileOwnerType.COMMENT, commentIds) + } + + private fun deleteFiles( + ownerType: FileOwnerType, + ownerIds: List, + ) { + if (ownerIds.isEmpty()) return + + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(ownerType, ownerIds) + } + + private fun markMembersPostLikesDeleted( + members: List, + now: LocalDateTime, + ) { + members + .groupBy { it.user.id } + .forEach { (userId, userMembers) -> + val clubIds = userMembers.map { it.club.id }.distinct().sorted() + val postIds = + postLikeRepository + .findActivePostIdsByUserIdAndClubIdIn( + userId = userId, + clubIds = clubIds, + ).distinct() + .sorted() + + if (postIds.isEmpty()) return@forEach + + val postsById = postRepository.findAllByIdsWithLock(postIds).associateBy { it.id } + + val likes = + postLikeRepository.findAllActiveByUserIdAndPostIds( + userId = userId, + postIds = postIds, + ) + + for (like in likes) { + if (!like.markDeleted(now)) continue + postsById[like.post.id]?.decreaseLikeCount() + } } } } diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index ffdc7170..04da18b9 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -15,7 +15,6 @@ import com.weeth.domain.comment.domain.repository.CommentRepository import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,7 +24,6 @@ class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, // 타 도메인 이므로 Reader 사용 검토 private val clubMemberPolicy: ClubMemberPolicy, - private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, ) : PostCommentUsecase { @@ -64,7 +62,7 @@ class ManageCommentUseCase( commentId: Long, userId: Long, ) { - val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + val comment = findCommentWithLock(commentId, postId) ensureOwner(comment, userId) ensureNotDeleted(comment) @@ -79,7 +77,7 @@ class ManageCommentUseCase( userId: Long, ) { val post = findPostWithLock(postId) - val comment = commentRepository.findByIdAndPostId(commentId, postId) ?: throw CommentNotFoundException() + val comment = findCommentWithLock(commentId, postId) ensureOwner(comment, userId) deleteComment(comment) @@ -137,11 +135,7 @@ class ManageCommentUseCase( } private fun deleteCommentFiles(commentId: Long) { - val files = fileReader.findAll(FileOwnerType.COMMENT, commentId) - - if (files.isNotEmpty()) { - fileRepository.deleteAll(files) - } + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.COMMENT, commentId) } private fun ensureOwner( @@ -167,4 +161,9 @@ class ManageCommentUseCase( private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() + + private fun findCommentWithLock( + commentId: Long, + postId: Long, + ): Comment = commentRepository.findByIdAndPostIdWithLock(commentId, postId) ?: throw CommentNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index b511b037..3260a7b1 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -1,9 +1,13 @@ package com.weeth.domain.comment.domain.repository import com.weeth.domain.comment.domain.entity.Comment +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints import org.springframework.data.repository.query.Param interface CommentRepository : @@ -15,9 +19,40 @@ interface CommentRepository : postId: Long, ): Comment? + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + """ + SELECT c + FROM Comment c + WHERE c.id = :id + AND c.post.id = :postId + """, + ) + fun findByIdAndPostIdWithLock( + @Param("id") id: Long, + @Param("postId") postId: Long, + ): Comment? + @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) @Query("SELECT c FROM Comment c WHERE c.post.id = :postId") override fun findAllByPostId( @Param("postId") postId: Long, ): List + + @Query( + """ + SELECT c.id + FROM Comment c + WHERE c.clubMember.id IN :clubMemberIds + AND c.isDeleted = false + AND c.post.isDeleted = false + AND c.post.board.isDeleted = false + ORDER BY c.id ASC + """, + ) + fun findActiveIdsByClubMemberIdIn( + @Param("clubMemberIds") clubMemberIds: List, + ): List } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt index e7b1cadf..d259135f 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileRepository.kt @@ -4,6 +4,9 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface FileRepository : JpaRepository, @@ -41,6 +44,42 @@ interface FileRepository : status: FileStatus, ): Boolean + @Modifying(flushAutomatically = true) + @Query( + """ + DELETE FROM File f + WHERE f.ownerType = :ownerType + AND f.ownerId = :ownerId + AND f.status = com.weeth.domain.file.domain.enums.FileStatus.UPLOADED + """, + ) + fun hardDeleteActiveByOwnerTypeAndOwnerId( + @Param("ownerType") ownerType: FileOwnerType, + @Param("ownerId") ownerId: Long, + ): Int + + fun hardDeleteActiveByOwnerTypeAndOwnerIdIn( + ownerType: FileOwnerType, + ownerIds: List, + ): Int { + if (ownerIds.isEmpty()) return 0 + return hardDeleteActiveByOwnerTypeAndOwnerIdInInternal(ownerType, ownerIds) + } + + @Modifying(flushAutomatically = true) + @Query( + """ + DELETE FROM File f + WHERE f.ownerType = :ownerType + AND f.ownerId IN :ownerIds + AND f.status = com.weeth.domain.file.domain.enums.FileStatus.UPLOADED + """, + ) + fun hardDeleteActiveByOwnerTypeAndOwnerIdInInternal( + @Param("ownerType") ownerType: FileOwnerType, + @Param("ownerIds") ownerIds: List, + ): Int + override fun findAll( ownerType: FileOwnerType, ownerId: Long, diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt index 898fc3cc..de9a58cd 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -3,6 +3,8 @@ package com.weeth.domain.user.application.usecase.command import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserHasLeadClubException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase @@ -21,6 +23,7 @@ class LeaveUserUseCase( private val userReader: UserReader, private val clubMemberRepository: ClubMemberRepository, private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, + private val fileRepository: FileRepository, private val jwtManageUseCase: JwtManageUseCase, private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, private val meterRegistry: MeterRegistry, @@ -38,15 +41,20 @@ class LeaveUserUseCase( throw UserHasLeadClubException() } - activeMembers.forEach { member -> - clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) - member.leave(now) + if (activeMembers.isNotEmpty()) { + clubActivityDeletionPolicy.markMembersActivitiesDeleted(activeMembers, now) } + activeMembers.forEach { it.leave(now) } + deleteClubMemberProfileFiles(userId) user.leave(now) revokeTokensAfterCommit(userId) } + private fun deleteClubMemberProfileFiles(userId: Long) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) + } + private fun revokeTokensAfterCommit(userId: Long) { TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt index e185a4b0..5ba753b8 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt @@ -115,8 +115,7 @@ class ManageAccountTransactionUseCaseTest : every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileMapper.toFileResponse(any()) } returns receiptResponse every { fileRepository.saveAll(any>()) } answers { firstArg() } - every { fileRepository.deleteAll(any>()) } just runs - every { fileRepository.flush() } just runs + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 every { fileReader.findAll(any(), any(), any()) } returns emptyList() every { clubPermissionPolicy.requireAdmin(any(), userId) } returns adminMember } @@ -261,7 +260,7 @@ class ManageAccountTransactionUseCaseTest : response.hasReceipt shouldBe true response.receipts shouldBe listOf(receiptResponse) - verify(exactly = 0) { fileRepository.deleteAll(any>()) } + verify(exactly = 0) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.saveAll(any>()) } } @@ -269,18 +268,20 @@ class ManageAccountTransactionUseCaseTest : val account = AccountTestFixture.createAccount(id = accountId, currentBalance = 100_000) val transaction = appliedTransaction(account, AccountTransactionType.EXPENSE, 30_000) ReflectionTestUtils.setField(transaction, "id", transactionId) - val oldReceipt = receiptFile(ownerId = transactionId) every { accountRepository.findByIdWithLock(accountId) } returns account every { transactionRepository.findByIdAndDeletedAtIsNull(transactionId) } returns transaction - every { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, transactionId, any()) } returns - listOf(oldReceipt) val request = UpdateAccountTransactionRequest(files = emptyList()) val response = useCase.update(account.club.id, accountId, transactionId, request, userId) response.hasReceipt shouldBe false response.receipts shouldBe emptyList() - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldReceipt)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId( + FileOwnerType.ACCOUNT_TRANSACTION, + transactionId, + ) + } verify(exactly = 0) { fileRepository.saveAll(any>()) } } @@ -288,12 +289,9 @@ class ManageAccountTransactionUseCaseTest : val account = AccountTestFixture.createAccount(id = accountId, currentBalance = 100_000) val transaction = appliedTransaction(account, AccountTransactionType.EXPENSE, 30_000) ReflectionTestUtils.setField(transaction, "id", transactionId) - val oldReceipt = receiptFile(id = 199L, ownerId = transactionId, fileName = "old.png") val newReceipt = receiptFile(id = 200L, ownerId = transactionId, fileName = "receipt.png") every { accountRepository.findByIdWithLock(accountId) } returns account every { transactionRepository.findByIdAndDeletedAtIsNull(transactionId) } returns transaction - every { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, transactionId, any()) } returns - listOf(oldReceipt) every { fileMapper.toFileList(listOf(receiptRequest), FileOwnerType.ACCOUNT_TRANSACTION, transactionId) } returns listOf(newReceipt) @@ -304,7 +302,12 @@ class ManageAccountTransactionUseCaseTest : response.hasReceipt shouldBe true response.receipts shouldBe listOf(receiptResponse) - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldReceipt)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId( + FileOwnerType.ACCOUNT_TRANSACTION, + transactionId, + ) + } verify(exactly = 1) { fileRepository.saveAll(listOf(newReceipt)) } } @@ -346,7 +349,7 @@ class ManageAccountTransactionUseCaseTest : account.currentBalance shouldBe 100_000 transaction.deletedAt.shouldNotBeNull() - verify(exactly = 0) { fileRepository.deleteAll(any>()) } + verify(exactly = 0) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } } it("시스템 거래(CARRY_OVER) 삭제 요청은 거부한다") { diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 0fd0ea15..68b7a3ac 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -25,7 +25,6 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -45,7 +44,6 @@ class ManagePostUseCaseTest : val clubMemberPolicy = mockk(relaxed = true) val clubMemberCardinalReader = mockk() val fileRepository = mockk() - val fileReader = mockk() val fileMapper = mockk() val postMapper = mockk() @@ -56,7 +54,6 @@ class ManagePostUseCaseTest : clubMemberPolicy, clubMemberCardinalReader, fileRepository, - fileReader, fileMapper, postMapper, ) @@ -81,17 +78,15 @@ class ManagePostUseCaseTest : clubMemberPolicy, clubMemberCardinalReader, fileRepository, - fileReader, fileMapper, postMapper, ) every { postRepository.save(any()) } answers { firstArg() } every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() every { fileRepository.saveAll(any>()) } returns emptyList() - every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(id = 1L, boardId = 1L) every { fileRepository.delete(any()) } just runs - every { fileRepository.flush() } just runs every { clubMemberCardinalReader.findLatestCardinalByClubMember(any()) } returns null } @@ -192,21 +187,19 @@ class ManagePostUseCaseTest : val post = Post.create("제목", "내용", ownerMember, board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post useCase.update(clubId, board.id, 1L, request, 1L) - verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.saveAll(any>()) } } it("files가 있으면 기존 파일을 삭제 후 교체한다") { - val user = UserTestFixture.createActiveUser1(1L) val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - val oldFile = createUploadedPostFile("old.png") val newFiles = listOf(createUploadedPostFile("new.png")) val request = UpdatePostRequest( @@ -223,9 +216,7 @@ class ManagePostUseCaseTest : ), ) - every { postRepository.findActivePostById(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) - every { fileRepository.deleteAll(any>()) } just runs + every { postRepository.findByIdWithLock(1L) } returns post every { fileMapper.toFileList(request.files, FileOwnerType.POST, any()) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles @@ -233,7 +224,9 @@ class ManagePostUseCaseTest : post.title shouldBe "수정" post.content shouldBe "수정" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify( + exactly = 1, + ) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.POST, post.id) } verify(exactly = 1) { fileRepository.saveAll(newFiles) } } @@ -244,7 +237,7 @@ class ManagePostUseCaseTest : val post = Post.create("원래 제목", "원래 내용", ownerMember, board) val request = UpdatePostRequest(content = "수정된 내용") - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post useCase.update(clubId, board.id, 1L, request, 1L) @@ -259,7 +252,7 @@ class ManagePostUseCaseTest : val post = Post.create("원래 제목", "원래 내용", ownerMember, board) val request = UpdatePostRequest(title = "수정된 제목") - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post useCase.update(clubId, board.id, 1L, request, 1L) @@ -268,7 +261,7 @@ class ManagePostUseCaseTest : } it("삭제된 Board 소속 Post를 수정하면 예외를 던진다") { - every { postRepository.findActivePostById(1L) } returns null + every { postRepository.findByIdWithLock(1L) } returns null shouldThrow { useCase.update(1L, 0L, 1L, UpdatePostRequest(title = "수정"), 1L) @@ -280,7 +273,7 @@ class ManagePostUseCaseTest : val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", board = board) - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.update(clubId, board.id + 1, 1L, UpdatePostRequest(title = "수정"), 1L) @@ -298,7 +291,7 @@ class ManagePostUseCaseTest : val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) @@ -316,7 +309,7 @@ class ManagePostUseCaseTest : val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.update(clubId, board.id, 1L, UpdatePostRequest(title = "수정"), 1L) @@ -325,27 +318,25 @@ class ManagePostUseCaseTest : } describe("delete") { - it("삭제 시 첨부 파일을 삭제하고 게시글을 soft delete한다") { - val user = UserTestFixture.createActiveUser1(1L) + it("삭제 시 첨부 파일을 하드 딜리트하고 게시글을 soft delete한다") { val board = BoardTestFixture.create(name = "일반", type = BoardType.GENERAL) val clubId = board.club.id val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - val oldFile = createUploadedPostFile("old.png") - every { postRepository.findActivePostById(1L) } returns post - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns listOf(oldFile) - every { fileRepository.deleteAll(any>()) } just runs + every { postRepository.findByIdWithLock(1L) } returns post useCase.delete(clubId, board.id, 1L, 1L) post.isDeleted shouldBe true - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify( + exactly = 1, + ) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.POST, post.id) } verify(exactly = 0) { postRepository.delete(any()) } } it("삭제된 Board 소속 Post를 삭제하면 예외를 던진다") { - every { postRepository.findActivePostById(1L) } returns null + every { postRepository.findByIdWithLock(1L) } returns null shouldThrow { useCase.delete(1L, 0L, 1L, 1L) @@ -357,7 +348,7 @@ class ManagePostUseCaseTest : val clubId = board.club.id val post = PostTestFixture.create(title = "제목", content = "내용", board = board) - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.delete(clubId, board.id + 1, 1L, 1L) @@ -375,7 +366,7 @@ class ManagePostUseCaseTest : val ownerMember = ClubMemberTestFixture.createActiveMember(user = UserTestFixture.createActiveUser1(1L)) val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.delete(clubId, board.id, 1L, 1L) @@ -391,7 +382,7 @@ class ManagePostUseCaseTest : val post = PostTestFixture.create(title = "제목", content = "내용", clubMember = ownerMember, board = board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findActivePostById(1L) } returns post + every { postRepository.findByIdWithLock(1L) } returns post shouldThrow { useCase.update(clubId, board.id, 1L, request, 2L) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 3b6e1182..6e64defc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -155,7 +155,7 @@ class GetPostQueryServiceTest : every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { commentReader.findAllByPostId(any()) } returns emptyList() every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments - every { fileReader.findAll(FileOwnerType.POST, any(), any()) } returns files + every { fileReader.findAll(FileOwnerType.POST, post.id, FileStatus.UPLOADED) } returns files every { postLikeRepository.existsByPostAndUserIdAndIsActiveTrueAndDeletedAtIsNull( post, @@ -173,6 +173,7 @@ class GetPostQueryServiceTest : result.id shouldBe 1L result.comments.size shouldBe 1 result.fileUrls.size shouldBe 1 + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, post.id, FileStatus.UPLOADED) } } it("비공개 게시판 게시글은 일반 멤버에게 노출하지 않는다") { @@ -304,7 +305,8 @@ class GetPostQueryServiceTest : every { boardRepository.findAllByClubIdAndIsDeletedFalseOrderByDisplayOrderAscIdAsc(clubId) } returns listOf(board) every { postRepository.findAllActiveByBoardIds(any(), any()) } returns postSlice - every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } returns + emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response @@ -355,14 +357,15 @@ class GetPostQueryServiceTest : every { clubMemberPolicy.getActiveMember(clubId, userId) } returns member every { boardRepository.findByIdAndClubIdAndIsDeletedFalse(1L, clubId) } returns board every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice - every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } returns + emptyList() every { postLikeRepository.findLikedPostIds(any(), any()) } returns emptySet() every { postMapper.toListResponse(any(), any(), any(), any(), any()) } returns response val result = queryService.findPosts(clubId, userId, 1L, 0, 10) result.content.size shouldBe 1 - verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, any>(), any()) } + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(post.id), FileStatus.UPLOADED) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 1f168a13..a83f4e8c 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -23,10 +23,7 @@ import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.fixture.FileTestFixture import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture @@ -55,7 +52,6 @@ class ManageClubMemberUseCaseTest : val clubJoinPolicy = mockk() val clubActivityDeletionPolicy = mockk() val fileRepository = mockk() - val fileAccessUrlPort = mockk() val clock = Clock.fixed(Instant.parse("2026-06-08T03:00:00Z"), ZoneId.of("Asia/Seoul")) val useCase = @@ -70,7 +66,6 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy = clubJoinPolicy, clubActivityDeletionPolicy = clubActivityDeletionPolicy, fileRepository = fileRepository, - fileAccessUrlPort = fileAccessUrlPort, clock = clock, ) @@ -86,10 +81,10 @@ class ManageClubMemberUseCaseTest : clubJoinPolicy, clubActivityDeletionPolicy, fileRepository, - fileAccessUrlPort, ) every { clubMemberRepository.save(any()) } answers { firstArg() } every { fileRepository.save(any()) } answers { firstArg() } + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 } describe("updateProfile") { @@ -103,32 +98,22 @@ class ManageClubMemberUseCaseTest : ) context("프로필 사진만 변경할 때") { - it("모든 활성 ClubMember의 기존 파일을 삭제하고 새 파일로 URL을 업데이트한다") { + it("모든 활성 ClubMember의 기존 파일을 하드 딜리트하고 새 파일로 URL을 업데이트한다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - val existingFile = - FileTestFixture.createFile( - id = 1L, - fileName = "old.png", - ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, - ownerId = userId, - ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) + useCase.updateProfile(userId, UpdateMemberProfileRequest(profileImage = profileImageRequest)) member1.profileImageStorageKey shouldBe profileImageRequest.storageKey member2.profileImageStorageKey shouldBe profileImageRequest.storageKey - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) + } verify(exactly = 1) { fileRepository.save(any()) } + verify(exactly = 1) { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } + verify(exactly = 0) { clubMemberRepository.findActiveByUserId(userId) } } } @@ -137,13 +122,14 @@ class ManageClubMemberUseCaseTest : val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) member1.bio shouldBe "안녕하세요!" member2.bio shouldBe "안녕하세요!" - verify(exactly = 0) { fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) } + verify(exactly = 0) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } } @@ -153,7 +139,8 @@ class ManageClubMemberUseCaseTest : val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "")) @@ -164,7 +151,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.updateProfile(userId, UpdateMemberProfileRequest(bio = "안녕하세요!")) @@ -177,33 +164,22 @@ class ManageClubMemberUseCaseTest : val userId = 10L context("활성 멤버가 프로필 사진을 삭제할 때") { - it("모든 활성 ClubMember의 파일을 삭제하고 URL을 null로 만든다") { + it("모든 활성 ClubMember의 파일을 하드 딜리트하고 URL을 null로 만든다") { val member1 = ClubMemberTestFixture.createActiveMember(id = 1L) val member2 = ClubMemberTestFixture.createActiveMember(id = 2L) member1.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") member2.updateProfileImageUrl("CLUB_MEMBER_PROFILE/2026-02/uuid_profile.png") - val existingFile = - FileTestFixture.createFile( - id = 1L, - fileName = "profile.png", - ownerType = FileOwnerType.CLUB_MEMBER_PROFILE, - ownerId = userId, - ) - every { clubMemberRepository.findActiveByUserId(userId) } returns listOf(member1, member2) - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_MEMBER_PROFILE, - userId, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } returns - Unit + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns + listOf(member1, member2) useCase.deleteProfileImage(userId) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, userId) + } + verify(exactly = 1) { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } + verify(exactly = 0) { clubMemberRepository.findActiveByUserId(userId) } member1.profileImageStorageKey shouldBe null member2.profileImageStorageKey shouldBe null } @@ -211,7 +187,7 @@ class ManageClubMemberUseCaseTest : context("활성 동아리 멤버십이 없을 때") { it("ClubMemberNotFoundException을 던진다") { - every { clubMemberRepository.findActiveByUserId(userId) } returns emptyList() + every { clubMemberRepository.findAllActiveByUserIdWithLock(userId) } returns emptyList() shouldThrow { useCase.deleteProfileImage(userId) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index d2bd81c5..8fe8c4e8 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -23,7 +23,6 @@ import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader @@ -91,9 +90,7 @@ class ManageClubUseCaseTest : every { clubRepository.existsBySchoolNameAndName(any(), any()) } returns false every { clubMapper.toCreateResponse(any()) } returns ClubCreateResponse(clubId = "testId", clubName = "테스트") every { fileRepository.save(any()) } answers { firstArg() } - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) - } returns emptyList() + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 } describe("create") { @@ -349,7 +346,7 @@ class ManageClubUseCaseTest : ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.update( 1L, @@ -369,8 +366,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("프로필 이미지를 변경하면 기존 File이 삭제되고 새 File이 생성된다") { - val existingFile = mockk(relaxed = true) + it("프로필 이미지를 변경하면 기존 File이 하드 딜리트되고 새 File이 생성된다") { val club = ClubTestFixture.createClub( clubContact = @@ -380,17 +376,8 @@ class ManageClubUseCaseTest : primaryContact = PrimaryContact.PHONE, ), ) - every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_PROFILE, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.update( 1L, @@ -406,7 +393,9 @@ class ManageClubUseCaseTest : ), ) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_PROFILE, 1L) + } verify(exactly = 1) { fileRepository.save(any()) } club.profileImageStorageKey shouldBe "CLUB_PROFILE/2026-03/550e8400-e29b-41d4-a716-446655440002_new.png" } @@ -422,13 +411,11 @@ class ManageClubUseCaseTest : ), ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.update(1L, 10L, ClubUpdateRequest(name = "새 이름")) - verify(exactly = 0) { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus(any(), any(), any()) - } + verify(exactly = 0) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { fileRepository.save(any()) } } @@ -444,7 +431,7 @@ class ManageClubUseCaseTest : ), ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.update(1L, 10L, ClubUpdateRequest()) @@ -479,7 +466,7 @@ class ManageClubUseCaseTest : ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.deleteProfileImage(1L, 10L) @@ -487,8 +474,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe "CLUB_BACKGROUND/2026-02/uuid_background.png" } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 하드 딜리트된다") { val club = ClubTestFixture.createClub( clubContact = @@ -498,21 +484,14 @@ class ManageClubUseCaseTest : primaryContact = PrimaryContact.PHONE, ), ) - every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_PROFILE, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.deleteProfileImage(1L, 10L) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_PROFILE, 1L) + } } } @@ -539,7 +518,7 @@ class ManageClubUseCaseTest : ) every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.deleteBackgroundImage(1L, 10L) @@ -547,8 +526,7 @@ class ManageClubUseCaseTest : club.backgroundImageStorageKey shouldBe null } - it("기존 File 레코드가 삭제된다") { - val existingFile = mockk(relaxed = true) + it("기존 File 레코드가 하드 딜리트된다") { val club = ClubTestFixture.createClub( clubContact = @@ -558,21 +536,14 @@ class ManageClubUseCaseTest : primaryContact = PrimaryContact.PHONE, ), ) - every { clubPermissionPolicy.requireAdmin(1L, 10L) } returns adminMember - every { clubRepository.getClubById(1L) } returns club - every { - fileRepository.findAllByOwnerTypeAndOwnerIdAndStatus( - FileOwnerType.CLUB_BACKGROUND, - 1L, - FileStatus.UPLOADED, - ) - } returns listOf(existingFile) - every { fileRepository.deleteAll(any>()) } just Runs + every { clubRepository.getClubByIdForUpdate(1L) } returns club useCase.deleteBackgroundImage(1L, 10L) - verify(exactly = 1) { fileRepository.deleteAll(listOf(existingFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_BACKGROUND, 1L) + } } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt index bb5fceab..7c68c0f5 100644 --- a/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicyTest.kt @@ -7,6 +7,9 @@ import com.weeth.domain.board.fixture.PostLikeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe @@ -21,10 +24,12 @@ class ClubActivityDeletionPolicyTest : DescribeSpec({ val postLikeRepository = mockk() val postRepository = mockk() - val policy = ClubActivityDeletionPolicy(postLikeRepository, postRepository) + val commentRepository = mockk() + val fileRepository = mockk() + val policy = ClubActivityDeletionPolicy(postLikeRepository, postRepository, commentRepository, fileRepository) beforeTest { - clearMocks(postLikeRepository, postRepository) + clearMocks(postLikeRepository, postRepository, commentRepository, fileRepository) } describe("markMemberActivitiesDeleted") { @@ -45,8 +50,10 @@ class ClubActivityDeletionPolicyTest : val now = LocalDateTime.of(2026, 5, 19, 12, 0) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns listOf(post.id) + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns listOf(post) every { postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) @@ -58,7 +65,7 @@ class ClubActivityDeletionPolicyTest : like.deletedAt shouldBe now post.likeCount shouldBe 0 verify(exactly = 1) { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } verify(exactly = 1) { postRepository.findAllByIdsWithLock(listOf(post.id)) @@ -77,13 +84,131 @@ class ClubActivityDeletionPolicyTest : ) every { - postLikeRepository.findActivePostIdsByUserIdAndClubId(member.user.id, club.id) + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() policy.markMemberActivitiesDeleted(member, LocalDateTime.of(2026, 5, 19, 12, 0)) verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } verify(exactly = 0) { postLikeRepository.findAllActiveByUserIdAndPostIds(any(), any()) } } + + it("삭제된 게시글에 남은 활성 좋아요도 삭제 마킹한다") { + val club = ClubTestFixture.createClub(id = 1L) + val member = + ClubMemberTestFixture.createActiveMember( + club = club, + user = UserTestFixture.createActiveUser1(id = 10L), + ) + val post = + PostTestFixture.create( + board = BoardTestFixture.create(club = club), + initialLikeCount = 1, + ) + ReflectionTestUtils.setField(post, "id", 100L) + post.markDeleted() + val like = PostLikeTestFixture.createActive(post = post, userId = member.user.id) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) + } returns listOf(post.id) + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns emptyList() + every { postRepository.findAllByIdsWithLock(listOf(post.id)) } returns emptyList() + every { + postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) + } returns listOf(like) + + policy.markMemberActivitiesDeleted(member, now) + + like.isActive shouldBe false + like.deletedAt shouldBe now + post.likeCount shouldBe 1 + verify(exactly = 1) { postRepository.findAllByIdsWithLock(listOf(post.id)) } + verify(exactly = 1) { + postLikeRepository.findAllActiveByUserIdAndPostIds(member.user.id, listOf(post.id)) + } + } + + it("작성한 게시글과 댓글의 파일을 하드 딜리트한다") { + val club = ClubTestFixture.createClub(id = 1L) + val member = + ClubMemberTestFixture.createActiveMember( + id = 10L, + club = club, + user = UserTestFixture.createActiveUser1(id = 20L), + ) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(member.user.id, listOf(club.id)) + } returns emptyList() + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns + listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(member.id)) } returns + listOf(200L) + every { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } returns 1 + every { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } returns 1 + + policy.markMemberActivitiesDeleted(member, now) + + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } + verify(exactly = 0) { postRepository.findAllByIdsWithLock(any()) } + } + } + + describe("markMembersActivitiesDeleted") { + it("여러 멤버의 게시글과 댓글 파일을 ownerType별 한 번씩 하드 딜리트한다") { + val club = ClubTestFixture.createClub(id = 1L) + val user = UserTestFixture.createActiveUser1(id = 20L) + val firstMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + club = club, + user = user, + ) + val secondMember = + ClubMemberTestFixture.createActiveMember( + id = 11L, + club = ClubTestFixture.createClub(id = 2L), + user = user, + ) + val now = LocalDateTime.of(2026, 5, 19, 12, 0) + + every { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(100L, 101L) + every { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } returns listOf(200L) + every { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } returns 1 + every { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } returns 1 + every { + postLikeRepository.findActivePostIdsByUserIdAndClubIdIn(user.id, listOf(1L, 2L)) + } returns emptyList() + + policy.markMembersActivitiesDeleted(listOf(firstMember, secondMember), now) + + verify(exactly = 1) { postRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { commentRepository.findActiveIdsByClubMemberIdIn(listOf(10L, 11L)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(100L, 101L)) + } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.COMMENT, listOf(200L)) + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index 8acc5719..1d4ab7be 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -16,8 +16,6 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType -import com.weeth.domain.file.domain.enums.FileStatus -import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -35,7 +33,6 @@ class ManageCommentUseCaseTest : val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() val clubMemberPolicy = mockk(relaxed = true) - val fileReader = mockk() val fileRepository = mockk(relaxed = true) val fileMapper = mockk() @@ -44,17 +41,16 @@ class ManageCommentUseCaseTest : commentRepository, postRepository, clubMemberPolicy, - fileReader, fileRepository, fileMapper, ) beforeTest { - clearMocks(commentRepository, postRepository, clubMemberPolicy, fileReader, fileRepository, fileMapper) + clearMocks(commentRepository, postRepository, clubMemberPolicy, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } - every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() every { commentRepository.delete(any()) } just runs + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 } describe("savePostComment") { @@ -92,14 +88,14 @@ class ManageCommentUseCaseTest : val comment = CommentTestFixture.createPostComment(id = 200L, post = post, clubMember = ownerMember) val dto = CommentUpdateRequest(content = "new", files = null) - every { commentRepository.findByIdAndPostId(200L, 10L) } returns comment + every { commentRepository.findByIdAndPostIdWithLock(200L, 10L) } returns comment shouldThrow { useCase.updatePostComment(dto, postId = 10L, commentId = 200L, userId = 2L) } } - it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { + it("files가 있으면 기존 파일은 하드 딜리트되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) val ownerMember = ClubMemberTestFixture.createActiveMember(user = owner) val post = PostTestFixture.create() @@ -117,15 +113,6 @@ class ManageCommentUseCaseTest : ), ), ) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174002_old.png", - fileSize = 200L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) val newFile = File.createUploaded( fileName = "new.png", @@ -136,14 +123,15 @@ class ManageCommentUseCaseTest : ownerId = comment.id, ) - every { commentRepository.findByIdAndPostId(202L, 10L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 202L, any()) } returns listOf(oldFile) + every { commentRepository.findByIdAndPostIdWithLock(202L, 10L) } returns comment every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, 202L) } returns listOf(newFile) useCase.updatePostComment(dto, postId = 10L, commentId = 202L, userId = 1L) comment.content shouldBe "new content" - verify(exactly = 1) { fileRepository.deleteAll(listOf(oldFile)) } + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.COMMENT, comment.id) + } verify { fileRepository.saveAll(listOf(newFile)) } } } @@ -159,7 +147,7 @@ class ManageCommentUseCaseTest : val comment = CommentTestFixture.createPostComment(id = 310L, post = post, clubMember = ownerMember) every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment + every { commentRepository.findByIdAndPostIdWithLock(310L, 10L) } returns comment useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) @@ -187,7 +175,7 @@ class ManageCommentUseCaseTest : comment.children.add(child) every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + every { commentRepository.findByIdAndPostIdWithLock(300L, 10L) } returns comment useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) @@ -210,7 +198,7 @@ class ManageCommentUseCaseTest : ) every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(320L, 10L) } returns comment + every { commentRepository.findByIdAndPostIdWithLock(320L, 10L) } returns comment shouldThrow { useCase.deletePostComment(postId = 10L, commentId = 320L, userId = 1L) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index c2abfcc7..fae469b0 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -8,6 +8,7 @@ import com.weeth.domain.comment.application.mapper.CommentMapper import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.user.application.dto.response.UserInfo import com.weeth.domain.user.fixture.UserTestFixture @@ -59,14 +60,14 @@ class GetCommentQueryServiceTest : val comment = CommentTestFixture.createPostComment(id = 1L, post = post, clubMember = member) val response = stubResponse(1L) - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), FileStatus.UPLOADED) } returns emptyList() every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response val result = service.toCommentTreeResponses(listOf(comment)) result.size shouldBe 1 result[0].id shouldBe 1L - verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } + verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), FileStatus.UPLOADED) } } it("부모-자식 구조를 트리로 조립한다") { @@ -81,7 +82,8 @@ class GetCommentQueryServiceTest : val childResponse = stubResponse(11L) val parentResponse = stubResponse(10L, children = listOf(childResponse)) - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), FileStatus.UPLOADED) } returns + emptyList() every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index c6a803c0..428c0120 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -10,6 +10,7 @@ import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import jakarta.persistence.EntityManager import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import @@ -23,6 +24,7 @@ import java.util.UUID class FileRepositoryTest( private val fileRepository: FileRepository, private val jdbcTemplate: JdbcTemplate, + private val entityManager: EntityManager, ) : DescribeSpec({ describe("save") { it("파일 정보를 저장하고 조회한다") { @@ -46,6 +48,27 @@ class FileRepositoryTest( } describe("findAll/exists") { + it("기본 단건 owner 조회는 업로드 상태인 파일만 반환한다") { + fileRepository.save(createTestFile("target.png", FileOwnerType.COMMENT, 77L, FileStatus.UPLOADED)) + fileRepository.save( + createTestFile("status-deleted.png", FileOwnerType.COMMENT, 77L, FileStatus.DELETED), + ) + + val files = fileRepository.findAll(FileOwnerType.COMMENT, 77L) + + files.map { it.fileName } shouldContainExactly listOf("target.png") + } + + it("기본 ownerId 목록 조회는 업로드 상태인 파일만 반환한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("status-deleted.png", FileOwnerType.POST, 78L, FileStatus.DELETED)) + + val files = fileRepository.findAll(FileOwnerType.POST, listOf(77L, 78L)) + + files.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + } + it("ownerType + ownerId + status 조건에 맞는 데이터만 조회한다") { fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) @@ -64,6 +87,88 @@ class FileRepositoryTest( fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.DELETED).shouldBeTrue() fileRepository.exists(FileOwnerType.POST, 99L, FileStatus.UPLOADED).shouldBeFalse() } + + it("ownerId 목록 조회에서도 업로드 상태인 파일만 반환한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + + val files = fileRepository.findAll(FileOwnerType.POST, listOf(77L, 78L), FileStatus.UPLOADED) + + files.map { it.fileName }.sorted() shouldContainExactly listOf("target-1.png", "target-2.png") + } + } + + describe("hard delete") { + it("삭제 후 같은 storageKey를 다시 저장할 수 있다") { + val storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_same.png" + fileRepository.saveAndFlush( + File.createUploaded( + fileName = "same.png", + storageKey = storageKey, + fileSize = 1024L, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 77L, + ), + ) + + val deletedCount = fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.POST, 77L) + + val recreated = + fileRepository.saveAndFlush( + File.createUploaded( + fileName = "same.png", + storageKey = storageKey, + fileSize = 1024L, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 77L, + ), + ) + + deletedCount shouldBe 1 + recreated.storageKey.value shouldBe storageKey + } + + it("ownerId 목록의 업로드 상태 파일을 한 번에 삭제한다") { + fileRepository.save(createTestFile("target-1.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 78L, FileStatus.DELETED)) + fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 79L, FileStatus.UPLOADED)) + fileRepository.flush() + + val deletedCount = + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn(FileOwnerType.POST, listOf(77L, 78L)) + + deletedCount shouldBe 2 + fileRepository.exists(FileOwnerType.POST, 77L, FileStatus.UPLOADED).shouldBeFalse() + fileRepository.exists(FileOwnerType.POST, 78L, FileStatus.UPLOADED).shouldBeFalse() + fileRepository.exists(FileOwnerType.POST, 78L, FileStatus.DELETED).shouldBeTrue() + fileRepository.exists(FileOwnerType.POST, 79L, FileStatus.UPLOADED).shouldBeTrue() + } + + it("bulk 삭제 후 영속성 컨텍스트의 다른 엔티티를 clear하지 않는다") { + val retained = + fileRepository.saveAndFlush( + createTestFile("retained.png", FileOwnerType.POST, 79L, FileStatus.UPLOADED), + ) + fileRepository.saveAndFlush(createTestFile("target.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) + val managedRetained = fileRepository.findById(retained.id).orElseThrow() + + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.POST, 77L) + + entityManager.contains(managedRetained).shouldBeTrue() + } + + it("ownerId 목록이 비어 있으면 쿼리를 실행하지 않고 0을 반환한다") { + val deletedCount = + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerIdIn( + FileOwnerType.POST, + emptyList(), + ) + + deletedCount shouldBe 0 + } } describe("index usage") { diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt index c96783c5..a3d73a63 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt @@ -5,6 +5,8 @@ import com.weeth.domain.club.domain.enums.MemberStatus import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.application.exception.UserHasLeadClubException import com.weeth.domain.user.domain.enums.Status import com.weeth.domain.user.domain.repository.UserReader @@ -31,6 +33,7 @@ class LeaveUserUseCaseTest : val userReader = mockk() val clubMemberRepository = mockk() val clubActivityDeletionPolicy = mockk() + val fileRepository = mockk() val jwtManageUseCase = mockk() val accessTokenBlacklistStore = mockk() val meterRegistry = SimpleMeterRegistry() @@ -40,6 +43,7 @@ class LeaveUserUseCaseTest : userReader = userReader, clubMemberRepository = clubMemberRepository, clubActivityDeletionPolicy = clubActivityDeletionPolicy, + fileRepository = fileRepository, jwtManageUseCase = jwtManageUseCase, accessTokenBlacklistStore = accessTokenBlacklistStore, meterRegistry = meterRegistry, @@ -51,9 +55,11 @@ class LeaveUserUseCaseTest : userReader, clubMemberRepository, clubActivityDeletionPolicy, + fileRepository, jwtManageUseCase, accessTokenBlacklistStore, ) + every { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } returns 0 if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization() } @@ -173,7 +179,7 @@ class LeaveUserUseCaseTest : val now = LocalDateTime.now(clock) every { userReader.getByIdWithLock(1L) } returns user every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) - justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + justRun { clubActivityDeletionPolicy.markMembersActivitiesDeleted(any(), any()) } justRun { jwtManageUseCase.deleteRefreshToken(1L) } justRun { accessTokenBlacklistStore.blacklist(1L) } TransactionSynchronizationManager.initSynchronization() @@ -185,8 +191,25 @@ class LeaveUserUseCaseTest : adminMember.memberStatus shouldBe MemberStatus.LEFT adminMember.leftAt shouldBe now user.status shouldBe Status.LEFT - verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(userMember, now) } - verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(adminMember, now) } + verify(exactly = 1) { + clubActivityDeletionPolicy.markMembersActivitiesDeleted(listOf(userMember, adminMember), now) + } + verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + } + + it("위드 탈퇴 시 멤버 프로필 파일을 하드 딜리트한다") { + val user = UserTestFixture.createRegisteredUser(1L) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + verify(exactly = 1) { + fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(FileOwnerType.CLUB_MEMBER_PROFILE, 1L) + } } it("ACTIVE LEAD 멤버십이 있으면 탈퇴를 차단하고 상태를 변경하지 않는다") { @@ -207,6 +230,9 @@ class LeaveUserUseCaseTest : user.status shouldBe Status.ACTIVE leadMember.memberStatus shouldBe MemberStatus.ACTIVE verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + verify( + exactly = 0, + ) { fileRepository.hardDeleteActiveByOwnerTypeAndOwnerId(any(), any()) } verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } } }