From c5e616e5f6c68eac80c8f2a3ecf1f1d7e883155c Mon Sep 17 00:00:00 2001 From: EH-OI Date: Tue, 11 Aug 2026 16:44:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=ED=86=B5?= =?UTF-8?q?=EC=A7=B8=20=EC=82=AD=EC=A0=9C=20=EC=A0=84=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=EB=90=90=EB=8D=98=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EA=B0=80=20=ED=8F=B4=EB=8D=94=20=EB=B3=B5=EA=B5=AC=20=EC=8B=9C?= =?UTF-8?q?=20=ED=95=A8=EA=BB=98=20=EB=B6=80=ED=99=9C=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/folder/service/FolderService.java | 2 +- .../repository/MaterialRepository.java | 138 ++++++++++++------ .../repository/MaterialRepositoryTest.java | 67 +++++++-- 3 files changed, 150 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java b/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java index ebb8118..ee8f9a9 100644 --- a/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java +++ b/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java @@ -203,7 +203,7 @@ public FolderTrashResponse moveFolderToTrash( .orElseThrow(() -> resolveTrashLookupException(userId, folderId)); folder.delete(); - materialRepository.trashMaterialsByFolder(folderId, userId); + materialRepository.trashMaterialsByFolder(folderId, userId, folder.getDeletedAt()); return FolderTrashResponse.from(folder); } diff --git a/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java b/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java index 8a8eb19..7ceda08 100644 --- a/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java +++ b/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -77,15 +78,20 @@ int restoreDeletedMaterials( @Param("userId") Long userId ); - /** 자료 자신이 휴지통 상태이면서, 상위 폴더는 휴지통이 아닌(또는 폴더가 없는) 것만 노출한다. - * 폴더 삭제로 함께 휴지통 처리된 자료는 폴더 휴지통 목록에만 나타나야 하므로 여기서 제외한다. */ + /** + * 자료 자신이 휴지통 상태이면서, "폴더와 함께" 휴지통에 들어간 게 아닌 것만 노출한다 + * (그런 자료는 폴더 휴지통 상세 목록에만 나타나야 하므로 여기서 제외한다). + * 폴더가 없거나, 폴더가 활성 상태이거나, 폴더는 나중에 삭제됐지만 이 자료는 그 전에 + * 이미 개별적으로 삭제됐던 경우(m.deleted_at < f.deleted_at)는 폴더 삭제와 무관한 + * 독립적인 휴지통 항목이므로 그대로 노출한다. + */ @Query( value = """ SELECT m.* FROM materials m LEFT JOIN folders f ON f.id = m.folder_id WHERE m.user_id = :userId AND m.deleted_at IS NOT NULL - AND (m.folder_id IS NULL OR f.deleted_at IS NULL) + AND (m.folder_id IS NULL OR f.deleted_at IS NULL OR m.deleted_at < f.deleted_at) ORDER BY m.deleted_at DESC, m.id DESC """, countQuery = """ @@ -93,7 +99,7 @@ SELECT COUNT(*) FROM materials m LEFT JOIN folders f ON f.id = m.folder_id WHERE m.user_id = :userId AND m.deleted_at IS NOT NULL - AND (m.folder_id IS NULL OR f.deleted_at IS NULL) + AND (m.folder_id IS NULL OR f.deleted_at IS NULL OR m.deleted_at < f.deleted_at) """, nativeQuery = true ) @@ -105,7 +111,7 @@ SELECT COUNT(*) FROM materials m LEFT JOIN folders f ON f.id = m.folder_id WHERE m.user_id = :userId AND m.deleted_at IS NOT NULL - AND (m.folder_id IS NULL OR f.deleted_at IS NULL) + AND (m.folder_id IS NULL OR f.deleted_at IS NULL OR m.deleted_at < f.deleted_at) ORDER BY m.deleted_at ASC, m.id ASC """, countQuery = """ @@ -113,7 +119,7 @@ SELECT COUNT(*) FROM materials m LEFT JOIN folders f ON f.id = m.folder_id WHERE m.user_id = :userId AND m.deleted_at IS NOT NULL - AND (m.folder_id IS NULL OR f.deleted_at IS NULL) + AND (m.folder_id IS NULL OR f.deleted_at IS NULL OR m.deleted_at < f.deleted_at) """, nativeQuery = true ) @@ -204,13 +210,19 @@ Page searchFolderMaterials( List findAllByFolder_Id(Long folderId); - /** 폴더 삭제 시 그 폴더에 속한(아직 활성 상태인) 자료를 전부 휴지통 상태로 전환한다. */ + /** + * 폴더 삭제 시 그 폴더에 속한(아직 활성 상태인) 자료를 전부 휴지통 상태로 전환한다. + * deletedAt은 DB의 CURRENT_TIMESTAMP가 아니라 폴더 자신의 삭제 시각(folder.getDeletedAt())을 + * 그대로 넘겨받아 써야 한다 — 그래야 폴더와 "같이" 삭제된 자료만 폴더 복구 시 함께 복구되고, + * 폴더 삭제 이전에 이미 개별적으로 휴지통에 있던 자료는 (deleted_at < 폴더 deletedAt이므로) + * restoreTrashedMaterialsByFolder / countDeletedByFolderIdAndUserId 에서 자동으로 제외된다. + */ @Modifying @Query( value = """ UPDATE materials - SET deleted_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP + SET deleted_at = :deletedAt, + updated_at = :deletedAt WHERE folder_id = :folderId AND user_id = :userId AND deleted_at IS NULL @@ -219,19 +231,27 @@ Page searchFolderMaterials( ) int trashMaterialsByFolder( @Param("folderId") Long folderId, - @Param("userId") Long userId + @Param("userId") Long userId, + @Param("deletedAt") LocalDateTime deletedAt ); - /** 폴더 복구 시 그 폴더 안에서 함께 휴지통으로 이동했던 자료를 폴더와 함께 전부 복구한다. */ + /** + * 폴더 복구 시 그 폴더 안에서 함께 휴지통으로 이동했던 자료를 폴더와 함께 복구한다. + * folders.deleted_at보다 먼저 삭제된(=폴더와 무관하게 개별적으로 먼저 휴지통에 있던) 자료는 + * 제외한다 — 그 자료들은 폴더 삭제와 상관없이 사용자가 별도로 지운 것이라, 폴더를 복구한다고 + * 같이 되살아나면 안 된다. + */ @Modifying @Query( value = """ - UPDATE materials - SET deleted_at = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL + UPDATE materials m + JOIN folders f ON f.id = m.folder_id + SET m.deleted_at = NULL, + m.updated_at = CURRENT_TIMESTAMP + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at """, nativeQuery = true ) @@ -240,13 +260,16 @@ int restoreTrashedMaterialsByFolder( @Param("userId") Long userId ); + /** restoreTrashedMaterialsByFolder와 동일한 기준(폴더와 함께 삭제된 자료만)으로 복구 대상 개수를 센다. */ @Query( value = """ SELECT COUNT(*) - FROM materials - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL + FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at """, nativeQuery = true ) @@ -287,15 +310,22 @@ List countDeletedByFolderIdForRestore( @Param("userId") Long userId ); - /** 휴지통 폴더 목록 조회용: 폴더와 함께 삭제된(휴지통으로 같이 이동한) 자료 개수를 폴더별로 집계한다. */ + /** + * 휴지통 폴더 목록 조회용: 폴더와 "함께" 삭제된(휴지통으로 같이 이동한) 자료 개수를 폴더별로 집계한다. + * findTrashedByFolderIdOrderByDeletedAtDesc/Asc, restoreTrashedMaterialsByFolder 와 동일한 기준 + * (m.deleted_at >= f.deleted_at)을 써야, 폴더 삭제 이전에 개별적으로 먼저 삭제됐던 자료가 + * 이 개수에 섞여 들어가지 않는다. + */ @Query( value = """ - SELECT folder_id AS folderId, COUNT(*) AS materialCount - FROM materials - WHERE folder_id IN (:folderIds) - AND user_id = :userId - AND deleted_at IS NOT NULL - GROUP BY folder_id + SELECT m.folder_id AS folderId, COUNT(*) AS materialCount + FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id IN (:folderIds) + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at + GROUP BY m.folder_id """, nativeQuery = true ) @@ -304,20 +334,30 @@ List countDeletedByFolderIdsAndUserId( @Param("userId") Long userId ); - /** 휴지통에 있는 폴더 상세 조회용: 그 폴더 안에 있던(폴더와 함께 휴지통으로 이동한) 자료를 최신순으로 조회한다. */ + /** + * 휴지통에 있는 폴더 상세 조회용: 그 폴더 안에 있던(폴더와 "함께" 휴지통으로 이동한) 자료를 + * 최신순으로 조회한다. 폴더 삭제 이전에 이미 개별적으로 삭제됐던 자료(m.deleted_at < f.deleted_at)는 + * 이 폴더와 무관한 독립적인 휴지통 항목이므로 제외한다 — countDeletedByFolderIdAndUserId / + * restoreTrashedMaterialsByFolder 와 동일한 기준을 사용해야 폴더 카드의 materialCount와 + * 상세 목록에 보이는 개수가 일치한다. + */ @Query( value = """ - SELECT * FROM materials - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL - ORDER BY deleted_at DESC, id DESC + SELECT m.* FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at + ORDER BY m.deleted_at DESC, m.id DESC """, countQuery = """ - SELECT COUNT(*) FROM materials - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL + SELECT COUNT(*) FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at """, nativeQuery = true ) @@ -329,17 +369,21 @@ Page findTrashedByFolderIdOrderByDeletedAtDesc( @Query( value = """ - SELECT * FROM materials - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL - ORDER BY deleted_at ASC, id ASC + SELECT m.* FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at + ORDER BY m.deleted_at ASC, m.id ASC """, countQuery = """ - SELECT COUNT(*) FROM materials - WHERE folder_id = :folderId - AND user_id = :userId - AND deleted_at IS NOT NULL + SELECT COUNT(*) FROM materials m + JOIN folders f ON f.id = m.folder_id + WHERE m.folder_id = :folderId + AND m.user_id = :userId + AND m.deleted_at IS NOT NULL + AND m.deleted_at >= f.deleted_at """, nativeQuery = true ) diff --git a/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java b/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java index 1785a17..6db89ea 100644 --- a/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java +++ b/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java @@ -12,8 +12,10 @@ import org.springframework.data.domain.PageRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -212,9 +214,14 @@ void findFolderNamesByIdsExcludesMaterialsOwnedByOtherUsers() { assertThat(result).isEmpty(); } - /** 휴지통 폴더 목록의 materialCount 계산에 쓰이는 쿼리 — 삭제된 자료만, 요청한 유저 소유만, 폴더별로 정확히 집계되는지 검증. */ + /** + * 휴지통 폴더 목록의 materialCount 계산에 쓰이는 쿼리. + * 폴더와 "함께" 삭제된 자료만 세야 하고, 폴더가 삭제되기 전에 이미 개별적으로 + * 휴지통에 있던 자료(deletedBeforeFolderTrashed)는 이 폴더의 개수에 섞이면 안 된다 + * — 섞이면 폴더 복구 시 그 자료까지 부활하는 버그로 이어진다(별도 테스트에서 검증). + */ @Test - void countDeletedByFolderIdsAndUserIdGroupsDeletedMaterialsPerFolderScopedToOwner() { + void countDeletedByFolderIdsAndUserIdOnlyCountsMaterialsDeletedTogetherWithTheFolder() { User owner = userRepository.save(user("trash-count-owner")); User other = userRepository.save(user("trash-count-other")); Folder folderWithTwoDeleted = folderRepository.save(Folder.create(owner, "Folder X")); @@ -222,14 +229,22 @@ void countDeletedByFolderIdsAndUserIdGroupsDeletedMaterialsPerFolderScopedToOwne Folder otherUsersFolder = folderRepository.save(Folder.create(other, "Folder Z")); Material activeInFolderX = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/active")); - Material deletedInFolderX1 = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/x1")); - Material deletedInFolderX2 = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/x2")); - Material deletedInFolderY = materialRepository.save(material(owner, folderWithOneDeleted, "https://example.com/y")); + Material deletedBeforeFolderTrashed = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/early")); + Material deletedWithFolderX1 = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/x1")); + Material deletedWithFolderX2 = materialRepository.save(material(owner, folderWithTwoDeleted, "https://example.com/x2")); + Material deletedWithFolderY = materialRepository.save(material(owner, folderWithOneDeleted, "https://example.com/y")); Material deletedInOthersFolder = materialRepository.save(material(other, otherUsersFolder, "https://example.com/z")); - deletedInFolderX1.delete(); - deletedInFolderX2.delete(); - deletedInFolderY.delete(); - deletedInOthersFolder.delete(); + + LocalDateTime beforeFolderTrashed = LocalDateTime.now().minusDays(1); + LocalDateTime folderTrashedAt = LocalDateTime.now(); + ReflectionTestUtils.setField(deletedBeforeFolderTrashed, "deletedAt", beforeFolderTrashed); + ReflectionTestUtils.setField(folderWithTwoDeleted, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(folderWithOneDeleted, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(otherUsersFolder, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(deletedWithFolderX1, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(deletedWithFolderX2, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(deletedWithFolderY, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(deletedInOthersFolder, "deletedAt", folderTrashedAt); flushAndClear(); List result = materialRepository.countDeletedByFolderIdsAndUserId( @@ -245,6 +260,40 @@ void countDeletedByFolderIdsAndUserIdGroupsDeletedMaterialsPerFolderScopedToOwne ); } + /** + * 폴더를 통째로 삭제했다가 복구할 때, 그 폴더 삭제 이전에 이미 개별적으로 휴지통에 + * 있던 자료까지 같이 부활하면 안 된다는 걸 검증하는 회귀 테스트. + */ + @Test + void restoreTrashedMaterialsByFolderDoesNotResurrectMaterialsDeletedBeforeTheFolderWasTrashed() { + User owner = userRepository.save(user("restore-scope-owner")); + Folder folder = folderRepository.save(Folder.create(owner, "Folder")); + Material deletedBeforeFolderTrashed = materialRepository.save(material(owner, folder, "https://example.com/early")); + Material deletedWithFolder = materialRepository.save(material(owner, folder, "https://example.com/with-folder")); + + LocalDateTime beforeFolderTrashed = LocalDateTime.now().minusDays(1); + LocalDateTime folderTrashedAt = LocalDateTime.now(); + ReflectionTestUtils.setField(deletedBeforeFolderTrashed, "deletedAt", beforeFolderTrashed); + ReflectionTestUtils.setField(folder, "deletedAt", folderTrashedAt); + ReflectionTestUtils.setField(deletedWithFolder, "deletedAt", folderTrashedAt); + flushAndClear(); + + materialRepository.restoreTrashedMaterialsByFolder(folder.getId(), owner.getId()); + flushAndClear(); + + // Material에도 @SQLRestriction("deleted_at IS NULL")이 걸려있어 findById로는 삭제 상태를 + // 확인할 수 없으므로(삭제된 행은 아예 안 보임), native 카운트 쿼리로 삭제 여부를 확인한다. + boolean earlyDeletedMaterialStillInTrash = materialRepository.countDeletedByMaterialIdsAndUserId( + List.of(deletedBeforeFolderTrashed.getId()), owner.getId() + ) == 1; + boolean cascadeDeletedMaterialWasRestored = materialRepository.countDeletedByMaterialIdsAndUserId( + List.of(deletedWithFolder.getId()), owner.getId() + ) == 0; + + assertThat(earlyDeletedMaterialStillInTrash).isTrue(); + assertThat(cascadeDeletedMaterialWasRestored).isTrue(); + } + private User user(String suffix) { return User.create( "material-repository-" + suffix + "@example.com", From 98ba034fa2407d6763244bdc08f2cbab24c2bbcd Mon Sep 17 00:00:00 2001 From: EH-OI Date: Tue, 11 Aug 2026 17:43:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=8F=B4=EB=8D=94=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EC=8B=9C=20=EC=9E=90=EB=A3=8C=20=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=EA=B0=80=20folders.deleted=5Fat=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=A1=B0=EC=9D=B8=EC=97=90=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=ED=95=B4=20=EC=95=84=EB=AC=B4=EA=B2=83?= =?UTF-8?q?=EB=8F=84=20=EB=B3=B5=EA=B5=AC=20=EB=AA=BB=20=ED=95=98=EB=8D=98?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(CodeRabbit=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/folder/service/FolderService.java | 11 ++- .../repository/MaterialRepository.java | 44 +++++---- .../domain/trash/service/TrashService.java | 15 ++- .../service/FolderRestoreIntegrationTest.java | 92 +++++++++++++++++++ .../folder/service/FolderServiceTest.java | 32 +++++-- .../repository/MaterialRepositoryTest.java | 3 +- .../trash/service/TrashServiceTest.java | 36 +++++++- 7 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 src/test/java/com/teaching/backend/domain/folder/service/FolderRestoreIntegrationTest.java diff --git a/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java b/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java index ee8f9a9..25d9cf8 100644 --- a/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java +++ b/src/main/java/com/teaching/backend/domain/folder/service/FolderService.java @@ -37,6 +37,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -230,14 +231,20 @@ public FolderRestoreResponse restoreFolder( throw new FolderException(FolderErrorCode.DUPLICATE_FOLDER_NAME); } - long materialRestoreCount = materialRepository.countDeletedByFolderIdAndUserId(folderId, userId); + // 폴더 자신의 deleted_at을 복구(NULL로 되돌리기) 전에 미리 스냅샷으로 떼어둔다 — + // restoreFolderOrThrowDuplicateName 이후에는 이 값을 더 이상 조회할 수 없다. + LocalDateTime folderDeletedAt = folderRepository.findTrashedByIdAndUserId(folderId, userId) + .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)) + .getDeletedAt(); + + long materialRestoreCount = materialRepository.countDeletedByFolderIdAndUserId(folderId, userId, folderDeletedAt); folderMaterialCapacityValidator.validateCanAdd(userId, folderId, materialRestoreCount); int restoredCount = restoreFolderOrThrowDuplicateName(folderId, userId); if (restoredCount == 0) { throw new FolderException(FolderErrorCode.FOLDER_NOT_FOUND); } - materialRepository.restoreTrashedMaterialsByFolder(folderId, userId); + materialRepository.restoreTrashedMaterialsByFolder(folderId, userId, folderDeletedAt); return FolderRestoreResponse.of(folderId, false); } diff --git a/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java b/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java index 7ceda08..b5b5e96 100644 --- a/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java +++ b/src/main/java/com/teaching/backend/domain/material/repository/MaterialRepository.java @@ -237,45 +237,51 @@ int trashMaterialsByFolder( /** * 폴더 복구 시 그 폴더 안에서 함께 휴지통으로 이동했던 자료를 폴더와 함께 복구한다. - * folders.deleted_at보다 먼저 삭제된(=폴더와 무관하게 개별적으로 먼저 휴지통에 있던) 자료는 - * 제외한다 — 그 자료들은 폴더 삭제와 상관없이 사용자가 별도로 지운 것이라, 폴더를 복구한다고 - * 같이 되살아나면 안 된다. + * folderDeletedAt(폴더가 삭제됐던 시각)보다 먼저 삭제된(=폴더와 무관하게 개별적으로 먼저 + * 휴지통에 있던) 자료는 제외한다 — 그 자료들은 폴더 삭제와 상관없이 사용자가 별도로 지운 + * 것이라, 폴더를 복구한다고 같이 되살아나면 안 된다. + * + * folders 테이블을 라이브로 조인하지 않고 folderDeletedAt을 호출자가 직접 넘기는 이유: + * 호출하는 쪽(FolderService.restoreFolder, TrashService.restoreFolders)에서 이 메서드보다 + * "폴더 자신의 deleted_at을 NULL로 되돌리는 복구"가 먼저 실행되면, 라이브 조인으로는 + * f.deleted_at이 이미 NULL이라 어떤 자료도 매칭되지 않는다 — 호출 순서에 안전하도록 + * 폴더가 삭제됐던 시각의 스냅샷을 호출 전에 미리 받아서 파라미터로 고정한다. */ @Modifying @Query( value = """ - UPDATE materials m - JOIN folders f ON f.id = m.folder_id - SET m.deleted_at = NULL, - m.updated_at = CURRENT_TIMESTAMP - WHERE m.folder_id = :folderId - AND m.user_id = :userId - AND m.deleted_at IS NOT NULL - AND m.deleted_at >= f.deleted_at + UPDATE materials + SET deleted_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE folder_id = :folderId + AND user_id = :userId + AND deleted_at IS NOT NULL + AND deleted_at >= :folderDeletedAt """, nativeQuery = true ) int restoreTrashedMaterialsByFolder( @Param("folderId") Long folderId, - @Param("userId") Long userId + @Param("userId") Long userId, + @Param("folderDeletedAt") LocalDateTime folderDeletedAt ); /** restoreTrashedMaterialsByFolder와 동일한 기준(폴더와 함께 삭제된 자료만)으로 복구 대상 개수를 센다. */ @Query( value = """ SELECT COUNT(*) - FROM materials m - JOIN folders f ON f.id = m.folder_id - WHERE m.folder_id = :folderId - AND m.user_id = :userId - AND m.deleted_at IS NOT NULL - AND m.deleted_at >= f.deleted_at + FROM materials + WHERE folder_id = :folderId + AND user_id = :userId + AND deleted_at IS NOT NULL + AND deleted_at >= :folderDeletedAt """, nativeQuery = true ) long countDeletedByFolderIdAndUserId( @Param("folderId") Long folderId, - @Param("userId") Long userId + @Param("userId") Long userId, + @Param("folderDeletedAt") LocalDateTime folderDeletedAt ); @Query( diff --git a/src/main/java/com/teaching/backend/domain/trash/service/TrashService.java b/src/main/java/com/teaching/backend/domain/trash/service/TrashService.java index 2766ae6..3b02a1c 100644 --- a/src/main/java/com/teaching/backend/domain/trash/service/TrashService.java +++ b/src/main/java/com/teaching/backend/domain/trash/service/TrashService.java @@ -44,10 +44,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -243,11 +245,20 @@ public FolderTrashRestoreResponse restoreFolders(Long userId, FolderIdsRequest r List restoredIds = new ArrayList<>(); for (Long folderId : requestedIds) { + // restoreTrashedFolderIfNameAvailable이 폴더의 deleted_at을 NULL로 되돌리므로, + // "폴더와 함께 삭제된 자료"를 가려낼 기준값(폴더가 삭제됐던 시각)은 그 전에 미리 떼어둔다. + Optional folderDeletedAt = folderRepository.findTrashedByIdAndUserId(folderId, userId) + .map(Folder::getDeletedAt); + if (folderDeletedAt.isEmpty()) { + continue; + } + if (folderRepository.restoreTrashedFolderIfNameAvailable(folderId, userId) > 0) { - long materialRestoreCount = materialRepository.countDeletedByFolderIdAndUserId(folderId, userId); + long materialRestoreCount = materialRepository.countDeletedByFolderIdAndUserId( + folderId, userId, folderDeletedAt.get()); folderMaterialCapacityValidator.validateCanAdd(userId, folderId, materialRestoreCount); restoredIds.add(folderId); - materialRepository.restoreTrashedMaterialsByFolder(folderId, userId); + materialRepository.restoreTrashedMaterialsByFolder(folderId, userId, folderDeletedAt.get()); } } diff --git a/src/test/java/com/teaching/backend/domain/folder/service/FolderRestoreIntegrationTest.java b/src/test/java/com/teaching/backend/domain/folder/service/FolderRestoreIntegrationTest.java new file mode 100644 index 0000000..082b16d --- /dev/null +++ b/src/test/java/com/teaching/backend/domain/folder/service/FolderRestoreIntegrationTest.java @@ -0,0 +1,92 @@ +package com.teaching.backend.domain.folder.service; + +import com.teaching.backend.domain.folder.entity.Folder; +import com.teaching.backend.domain.folder.dto.request.FolderIdsRequest; +import com.teaching.backend.domain.folder.repository.FolderRepository; +import com.teaching.backend.domain.material.entity.Material; +import com.teaching.backend.domain.material.enums.PlatformType; +import com.teaching.backend.domain.material.repository.MaterialRepository; +import com.teaching.backend.domain.trash.service.TrashService; +import com.teaching.backend.domain.user.entity.User; +import com.teaching.backend.domain.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * FolderService.restoreFolder / TrashService.restoreFolders 가 실제 DB 위에서 + * "폴더 자신을 먼저 복구한 뒤 자료를 복구"하는 순서로 동작해도, materialRepository의 + * 자료 복구/카운트 쿼리가 정상 동작하는지 검증한다 (mock 테스트는 호출 순서와 무관하게 + * 항상 같은 값을 리턴하므로 이 클래스의 버그를 못 잡는다 — 반드시 실제 DB 왕복이 필요). + */ +@SpringBootTest +@Transactional +class FolderRestoreIntegrationTest { + + @Autowired + private FolderService folderService; + + @Autowired + private TrashService trashService; + + @Autowired + private FolderRepository folderRepository; + + @Autowired + private MaterialRepository materialRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void restoreFolderRestoresMaterialsTrashedTogetherWithFolderEvenThoughFolderIsRestoredFirst() { + User user = userRepository.save(User.create("folder-restore-owner@example.com", "owner", null, null, null)); + Folder folder = folderRepository.save(Folder.create(user, "Folder")); + Material material = materialRepository.save(material(user, folder)); + flushAndClear(); + + folderService.moveFolderToTrash(user.getId(), folder.getId()); + flushAndClear(); + + folderService.restoreFolder(user.getId(), folder.getId()); + flushAndClear(); + + long stillDeletedCount = materialRepository.countDeletedByMaterialIdsAndUserId( + java.util.List.of(material.getId()), user.getId()); + assertThat(stillDeletedCount).isZero(); + } + + @Test + void restoreFoldersBatchRestoresMaterialsTrashedTogetherWithFolderEvenThoughFolderIsRestoredFirst() { + User user = userRepository.save(User.create("folder-batch-restore-owner@example.com", "owner", null, null, null)); + Folder folder = folderRepository.save(Folder.create(user, "Folder")); + Material material = materialRepository.save(material(user, folder)); + flushAndClear(); + + folderService.moveFolderToTrash(user.getId(), folder.getId()); + flushAndClear(); + + trashService.restoreFolders(user.getId(), new FolderIdsRequest(java.util.List.of(folder.getId()))); + flushAndClear(); + + long stillDeletedCount = materialRepository.countDeletedByMaterialIdsAndUserId( + java.util.List.of(material.getId()), user.getId()); + assertThat(stillDeletedCount).isZero(); + } + + private Material material(User user, Folder folder) { + return Material.create(user, folder, "Title", "https://example.com/" + System.identityHashCode(folder), PlatformType.WEB); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } +} diff --git a/src/test/java/com/teaching/backend/domain/folder/service/FolderServiceTest.java b/src/test/java/com/teaching/backend/domain/folder/service/FolderServiceTest.java index ebfb494..ca2bcce 100644 --- a/src/test/java/com/teaching/backend/domain/folder/service/FolderServiceTest.java +++ b/src/test/java/com/teaching/backend/domain/folder/service/FolderServiceTest.java @@ -293,29 +293,35 @@ void restoreDeletedFolderFailsWhenDeletedNameWasReusedByNewActiveFolder() { @Test void restoreFolderAlsoRestoresMaterialsTrashedTogetherWithFolder() { + LocalDateTime deletedAt = LocalDateTime.now(); when(userRepository.findByIdForUpdate(USER_ID)).thenReturn(Optional.of(user(USER_ID))); when(folderRepository.findByIdAndUser_Id(FOLDER_ID, USER_ID)).thenReturn(Optional.empty()); when(folderRepository.countByIdIncludingDeleted(FOLDER_ID)).thenReturn(1L); when(folderRepository.countByIdAndUserIdIncludingDeleted(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countDeletedByIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countActiveNameConflictForRestore(FOLDER_ID, USER_ID)).thenReturn(0L); - when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); + when(folderRepository.findTrashedByIdAndUserId(FOLDER_ID, USER_ID)) + .thenReturn(Optional.of(trashedFolder(USER_ID, FOLDER_ID, "Java", deletedAt))); + when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID, deletedAt)).thenReturn(1L); when(folderRepository.restoreDeletedFolder(FOLDER_ID, USER_ID)).thenReturn(1); folderService.restoreFolder(USER_ID, FOLDER_ID); - verify(materialRepository).restoreTrashedMaterialsByFolder(FOLDER_ID, USER_ID); + verify(materialRepository).restoreTrashedMaterialsByFolder(FOLDER_ID, USER_ID, deletedAt); } @Test void restoreFolderDoesNotApplyFreeFolderLimitToPremiumUser() { + LocalDateTime deletedAt = LocalDateTime.now(); when(userRepository.findByIdForUpdate(USER_ID)).thenReturn(Optional.of(user(USER_ID, MembershipType.PREMIUM))); when(folderRepository.findByIdAndUser_Id(FOLDER_ID, USER_ID)).thenReturn(Optional.empty()); when(folderRepository.countByIdIncludingDeleted(FOLDER_ID)).thenReturn(1L); when(folderRepository.countByIdAndUserIdIncludingDeleted(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countDeletedByIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countActiveNameConflictForRestore(FOLDER_ID, USER_ID)).thenReturn(0L); - when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(0L); + when(folderRepository.findTrashedByIdAndUserId(FOLDER_ID, USER_ID)) + .thenReturn(Optional.of(trashedFolder(USER_ID, FOLDER_ID, "Java", deletedAt))); + when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID, deletedAt)).thenReturn(0L); when(folderRepository.restoreDeletedFolder(FOLDER_ID, USER_ID)).thenReturn(1); assertThatCode(() -> folderService.restoreFolder(USER_ID, FOLDER_ID)) @@ -327,13 +333,16 @@ void restoreFolderDoesNotApplyFreeFolderLimitToPremiumUser() { @Test void restoreFolderFailsWhenRestoredMaterialsExceedFolderLimit() { + LocalDateTime deletedAt = LocalDateTime.now(); when(userRepository.findByIdForUpdate(USER_ID)).thenReturn(Optional.of(user(USER_ID))); when(folderRepository.findByIdAndUser_Id(FOLDER_ID, USER_ID)).thenReturn(Optional.empty()); when(folderRepository.countByIdIncludingDeleted(FOLDER_ID)).thenReturn(1L); when(folderRepository.countByIdAndUserIdIncludingDeleted(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countDeletedByIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countActiveNameConflictForRestore(FOLDER_ID, USER_ID)).thenReturn(0L); - when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); + when(folderRepository.findTrashedByIdAndUserId(FOLDER_ID, USER_ID)) + .thenReturn(Optional.of(trashedFolder(USER_ID, FOLDER_ID, "Java", deletedAt))); + when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID, deletedAt)).thenReturn(1L); doThrow(new MaterialException(MaterialErrorCode.FOLDER_MATERIAL_LIMIT_EXCEEDED)) .when(folderMaterialCapacityValidator).validateCanAdd(USER_ID, FOLDER_ID, 1L); @@ -343,25 +352,28 @@ void restoreFolderFailsWhenRestoredMaterialsExceedFolderLimit() { .isEqualTo(MaterialErrorCode.FOLDER_MATERIAL_LIMIT_EXCEEDED); verify(folderRepository, never()).restoreDeletedFolder(FOLDER_ID, USER_ID); - verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any()); + verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any(), any()); } @Test void restoreFolderDoesNotRestoreMaterialsWhenFolderRestoreFails() { + LocalDateTime deletedAt = LocalDateTime.now(); when(userRepository.findByIdForUpdate(USER_ID)).thenReturn(Optional.of(user(USER_ID))); when(folderRepository.findByIdAndUser_Id(FOLDER_ID, USER_ID)).thenReturn(Optional.empty()); when(folderRepository.countByIdIncludingDeleted(FOLDER_ID)).thenReturn(1L); when(folderRepository.countByIdAndUserIdIncludingDeleted(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countDeletedByIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); when(folderRepository.countActiveNameConflictForRestore(FOLDER_ID, USER_ID)).thenReturn(0L); - when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); + when(folderRepository.findTrashedByIdAndUserId(FOLDER_ID, USER_ID)) + .thenReturn(Optional.of(trashedFolder(USER_ID, FOLDER_ID, "Java", deletedAt))); + when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID, deletedAt)).thenReturn(1L); when(folderRepository.restoreDeletedFolder(FOLDER_ID, USER_ID)).thenReturn(0); assertFolderExceptionThrown( () -> folderService.restoreFolder(USER_ID, FOLDER_ID), FolderErrorCode.FOLDER_NOT_FOUND ); - verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any()); + verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any(), any()); } @Test @@ -537,6 +549,12 @@ private Folder folder(Long userId, Long folderId, String name) { return folder; } + private Folder trashedFolder(Long userId, Long folderId, String name, LocalDateTime deletedAt) { + Folder folder = folder(userId, folderId, name); + ReflectionTestUtils.setField(folder, "deletedAt", deletedAt); + return folder; + } + private Material material( Long materialId, Long userId, diff --git a/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java b/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java index 6db89ea..418dc2a 100644 --- a/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java +++ b/src/test/java/com/teaching/backend/domain/material/repository/MaterialRepositoryTest.java @@ -274,11 +274,10 @@ void restoreTrashedMaterialsByFolderDoesNotResurrectMaterialsDeletedBeforeTheFol LocalDateTime beforeFolderTrashed = LocalDateTime.now().minusDays(1); LocalDateTime folderTrashedAt = LocalDateTime.now(); ReflectionTestUtils.setField(deletedBeforeFolderTrashed, "deletedAt", beforeFolderTrashed); - ReflectionTestUtils.setField(folder, "deletedAt", folderTrashedAt); ReflectionTestUtils.setField(deletedWithFolder, "deletedAt", folderTrashedAt); flushAndClear(); - materialRepository.restoreTrashedMaterialsByFolder(folder.getId(), owner.getId()); + materialRepository.restoreTrashedMaterialsByFolder(folder.getId(), owner.getId(), folderTrashedAt); flushAndClear(); // Material에도 @SQLRestriction("deleted_at IS NULL")이 걸려있어 findById로는 삭제 상태를 diff --git a/src/test/java/com/teaching/backend/domain/trash/service/TrashServiceTest.java b/src/test/java/com/teaching/backend/domain/trash/service/TrashServiceTest.java index 227d78f..ffa5c3a 100644 --- a/src/test/java/com/teaching/backend/domain/trash/service/TrashServiceTest.java +++ b/src/test/java/com/teaching/backend/domain/trash/service/TrashServiceTest.java @@ -35,6 +35,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -42,6 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -183,16 +185,31 @@ void trashedFolderListDoesNotQueryMaterialCountsForEmptyPage() { @Test void restoreFoldersCascadesMaterialRestoreOnlyForActuallyRestoredFolders() { + LocalDateTime deletedAt = LocalDateTime.now(); + when(folderRepository.findTrashedByIdAndUserId(10L, USER_ID)) + .thenReturn(Optional.of(trashedFolder(10L, "A", deletedAt))); + when(folderRepository.findTrashedByIdAndUserId(20L, USER_ID)) + .thenReturn(Optional.of(trashedFolder(20L, "B", deletedAt))); when(folderRepository.restoreTrashedFolderIfNameAvailable(10L, USER_ID)).thenReturn(1); when(folderRepository.restoreTrashedFolderIfNameAvailable(20L, USER_ID)).thenReturn(0); - when(materialRepository.countDeletedByFolderIdAndUserId(10L, USER_ID)).thenReturn(1L); + when(materialRepository.countDeletedByFolderIdAndUserId(10L, USER_ID, deletedAt)).thenReturn(1L); FolderTrashRestoreResponse response = trashService.restoreFolders(USER_ID, new FolderIdsRequest(List.of(10L, 20L))); assertThat(response.restoredIds()).containsExactly(10L); assertThat(response.failedIds()).containsExactly(20L); - verify(materialRepository).restoreTrashedMaterialsByFolder(10L, USER_ID); - verify(materialRepository, never()).restoreTrashedMaterialsByFolder(20L, USER_ID); + verify(materialRepository).restoreTrashedMaterialsByFolder(10L, USER_ID, deletedAt); + verify(materialRepository, never()).restoreTrashedMaterialsByFolder(eq(20L), any(), any()); + } + + @Test + void restoreFoldersSkipsFolderNotFoundInTrash() { + when(folderRepository.findTrashedByIdAndUserId(10L, USER_ID)).thenReturn(Optional.empty()); + + FolderTrashRestoreResponse response = trashService.restoreFolders(USER_ID, new FolderIdsRequest(List.of(10L))); + + assertThat(response.failedIds()).containsExactly(10L); + verify(folderRepository, never()).restoreTrashedFolderIfNameAvailable(any(), any()); } @Test @@ -213,8 +230,11 @@ void restoreMaterialsFailsWhenOriginalFolderMaterialLimitExceeded() { @Test void restoreFoldersFailsWhenRestoredMaterialsExceedFolderLimit() { + LocalDateTime deletedAt = LocalDateTime.now(); + when(folderRepository.findTrashedByIdAndUserId(FOLDER_ID, USER_ID)) + .thenReturn(Optional.of(trashedFolder(FOLDER_ID, "Backend", deletedAt))); when(folderRepository.restoreTrashedFolderIfNameAvailable(FOLDER_ID, USER_ID)).thenReturn(1); - when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID)).thenReturn(1L); + when(materialRepository.countDeletedByFolderIdAndUserId(FOLDER_ID, USER_ID, deletedAt)).thenReturn(1L); doThrow(new MaterialException(MaterialErrorCode.FOLDER_MATERIAL_LIMIT_EXCEEDED)) .when(folderMaterialCapacityValidator).validateCanAdd(USER_ID, FOLDER_ID, 1L); @@ -223,7 +243,7 @@ void restoreFoldersFailsWhenRestoredMaterialsExceedFolderLimit() { .extracting("errorCode") .isEqualTo(MaterialErrorCode.FOLDER_MATERIAL_LIMIT_EXCEEDED); - verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any()); + verify(materialRepository, never()).restoreTrashedMaterialsByFolder(any(), any(), any()); } @Test @@ -285,6 +305,12 @@ private Folder folder(Long id, String name) { return folder; } + private Folder trashedFolder(Long id, String name, LocalDateTime deletedAt) { + Folder folder = folder(id, name); + ReflectionTestUtils.setField(folder, "deletedAt", deletedAt); + return folder; + } + private FolderMaterialRestoreCountProjection restoreCount(Long folderId, long materialCount) { return new FolderMaterialRestoreCountProjection() { @Override