diff --git a/src/main/java/com/teaching/backend/domain/folder/entity/Folder.java b/src/main/java/com/teaching/backend/domain/folder/entity/Folder.java index 12bf150..51a4267 100644 --- a/src/main/java/com/teaching/backend/domain/folder/entity/Folder.java +++ b/src/main/java/com/teaching/backend/domain/folder/entity/Folder.java @@ -26,6 +26,11 @@ public class Folder extends BaseSoftDeleteEntity { @Column(nullable = false, length = 100) private String name; + /** + * DB 컬럼(item_count)이 NOT NULL이고 기본값이 없어 필드 자체는 유지해야 하지만, + * 값은 어디에서도 갱신되지 않아 항상 0으로 고정된 죽은 필드다 — 읽지 말 것 + * (휴지통 폴더의 자료 개수는 TrashService 에서 native COUNT 쿼리로 별도 계산한다). + */ @Column(nullable = false) @Builder.Default private Integer itemCount = 0; 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 48c253d..8a8eb19 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 @@ -287,6 +287,23 @@ List countDeletedByFolderIdForRestore( @Param("userId") Long userId ); + /** 휴지통 폴더 목록 조회용: 폴더와 함께 삭제된(휴지통으로 같이 이동한) 자료 개수를 폴더별로 집계한다. */ + @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 + """, + nativeQuery = true + ) + List countDeletedByFolderIdsAndUserId( + @Param("folderIds") List folderIds, + @Param("userId") Long userId + ); + /** 휴지통에 있는 폴더 상세 조회용: 그 폴더 안에 있던(폴더와 함께 휴지통으로 이동한) 자료를 최신순으로 조회한다. */ @Query( value = """ diff --git a/src/main/java/com/teaching/backend/domain/trash/dto/response/TrashFolderItemResponse.java b/src/main/java/com/teaching/backend/domain/trash/dto/response/TrashFolderItemResponse.java index e8f577d..6f183b8 100644 --- a/src/main/java/com/teaching/backend/domain/trash/dto/response/TrashFolderItemResponse.java +++ b/src/main/java/com/teaching/backend/domain/trash/dto/response/TrashFolderItemResponse.java @@ -11,18 +11,12 @@ public record TrashFolderItemResponse( LocalDateTime deletedAt ) { - public static TrashFolderItemResponse from(Folder folder) { + public static TrashFolderItemResponse from(Folder folder, long materialCount) { return new TrashFolderItemResponse( folder.getId(), folder.getName(), - convertToLong(folder.getItemCount()), + materialCount, folder.getDeletedAt() ); } - - private static Long convertToLong(Integer itemCount) { - return itemCount == null - ? 0L - : itemCount.longValue(); - } } 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 6860072..2766ae6 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 @@ -87,7 +87,23 @@ public TrashFolderListResponse getTrashedFolders(Long userId, String sort, Integ ? folderRepository.findTrashedByUserIdOrderByDeletedAtAsc(userId, pageRequest) : folderRepository.findTrashedByUserIdOrderByDeletedAtDesc(userId, pageRequest); - return TrashFolderListResponse.of(folders.map(TrashFolderItemResponse::from)); + Map materialCountByFolderId = getDeletedMaterialCountByFolderId(folders.getContent(), userId); + + return TrashFolderListResponse.of(folders.map(folder -> + TrashFolderItemResponse.from(folder, materialCountByFolderId.getOrDefault(folder.getId(), 0L)) + )); + } + + private Map getDeletedMaterialCountByFolderId(List folders, Long userId) { + if (folders.isEmpty()) { + return Map.of(); + } + List folderIds = folders.stream().map(Folder::getId).toList(); + return materialRepository.countDeletedByFolderIdsAndUserId(folderIds, userId).stream() + .collect(Collectors.toMap( + FolderMaterialRestoreCountProjection::getFolderId, + FolderMaterialRestoreCountProjection::getMaterialCount + )); } public TrashMaterialListResponse getTrashedMaterials(Long userId, String sort, Integer page) { 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 fa28707..1785a17 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 @@ -212,6 +212,39 @@ void findFolderNamesByIdsExcludesMaterialsOwnedByOtherUsers() { assertThat(result).isEmpty(); } + /** 휴지통 폴더 목록의 materialCount 계산에 쓰이는 쿼리 — 삭제된 자료만, 요청한 유저 소유만, 폴더별로 정확히 집계되는지 검증. */ + @Test + void countDeletedByFolderIdsAndUserIdGroupsDeletedMaterialsPerFolderScopedToOwner() { + 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")); + Folder folderWithOneDeleted = folderRepository.save(Folder.create(owner, "Folder Y")); + 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 deletedInOthersFolder = materialRepository.save(material(other, otherUsersFolder, "https://example.com/z")); + deletedInFolderX1.delete(); + deletedInFolderX2.delete(); + deletedInFolderY.delete(); + deletedInOthersFolder.delete(); + flushAndClear(); + + List result = materialRepository.countDeletedByFolderIdsAndUserId( + List.of(folderWithTwoDeleted.getId(), folderWithOneDeleted.getId(), otherUsersFolder.getId()), + owner.getId() + ); + + assertThat(result) + .extracting(FolderMaterialRestoreCountProjection::getFolderId, FolderMaterialRestoreCountProjection::getMaterialCount) + .containsExactlyInAnyOrder( + tuple(folderWithTwoDeleted.getId(), 2L), + tuple(folderWithOneDeleted.getId(), 1L) + ); + } + private User user(String suffix) { return User.create( "material-repository-" + suffix + "@example.com", 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 a540645..227d78f 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 @@ -20,6 +20,7 @@ import com.teaching.backend.domain.tag.repository.MaterialTagRepository; import com.teaching.backend.domain.teachingmap.repository.TeachingMapRepository; import com.teaching.backend.domain.teachingmap.repository.TeachingMapStepRepository; +import com.teaching.backend.domain.trash.dto.response.TrashFolderListResponse; import com.teaching.backend.domain.trash.dto.response.TrashFolderMaterialListResponse; import com.teaching.backend.domain.trash.dto.response.TrashMaterialItemResponse; import com.teaching.backend.domain.trash.dto.response.TrashMaterialListResponse; @@ -140,6 +141,46 @@ void queriesTagRepositoryExactlyOnceForNonEmptyPage() { assertThat(materialIdsCaptor.getValue()).containsExactlyInAnyOrder(10L, 20L); } + /** + * 휴지통 폴더 목록의 materialCount는 폴더 엔티티의 죽은 itemCount 필드가 아니라, + * 삭제된 자료를 직접 COUNT한 값이어야 한다 (never-updated itemCount=0 회귀 방지). + */ + @Test + void trashedFolderListUsesLiveDeletedMaterialCountNotStaleItemCountField() { + Folder folder = folder(FOLDER_ID, "테스트테테"); + when(folderRepository.findTrashedByUserIdOrderByDeletedAtDesc(any(), any())) + .thenReturn(new PageImpl<>(List.of(folder))); + when(materialRepository.countDeletedByFolderIdsAndUserId(List.of(FOLDER_ID), USER_ID)) + .thenReturn(List.of(restoreCount(FOLDER_ID, 6L))); + + TrashFolderListResponse response = trashService.getTrashedFolders(USER_ID, null, 0); + + assertThat(response.content()).extracting("materialCount").containsExactly(6L); + } + + @Test + void trashedFolderListDefaultsToZeroWhenNoDeletedMaterialsMatchFolder() { + Folder folder = folder(FOLDER_ID, "빈폴더"); + when(folderRepository.findTrashedByUserIdOrderByDeletedAtDesc(any(), any())) + .thenReturn(new PageImpl<>(List.of(folder))); + when(materialRepository.countDeletedByFolderIdsAndUserId(List.of(FOLDER_ID), USER_ID)) + .thenReturn(List.of()); + + TrashFolderListResponse response = trashService.getTrashedFolders(USER_ID, null, 0); + + assertThat(response.content()).extracting("materialCount").containsExactly(0L); + } + + @Test + void trashedFolderListDoesNotQueryMaterialCountsForEmptyPage() { + when(folderRepository.findTrashedByUserIdOrderByDeletedAtDesc(any(), any())) + .thenReturn(new PageImpl<>(List.of())); + + trashService.getTrashedFolders(USER_ID, null, 0); + + verify(materialRepository, never()).countDeletedByFolderIdsAndUserId(anyList(), any()); + } + @Test void restoreFoldersCascadesMaterialRestoreOnlyForActuallyRestoredFolders() { when(folderRepository.restoreTrashedFolderIfNameAvailable(10L, USER_ID)).thenReturn(1);