Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,23 @@ List<FolderMaterialRestoreCountProjection> 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<FolderMaterialRestoreCountProjection> countDeletedByFolderIdsAndUserId(
@Param("folderIds") List<Long> folderIds,
@Param("userId") Long userId
);

/** 휴지통에 있는 폴더 상세 조회용: 그 폴더 안에 있던(폴더와 함께 휴지통으로 이동한) 자료를 최신순으로 조회한다. */
@Query(
value = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, Long> materialCountByFolderId = getDeletedMaterialCountByFolderId(folders.getContent(), userId);

return TrashFolderListResponse.of(folders.map(folder ->
TrashFolderItemResponse.from(folder, materialCountByFolderId.getOrDefault(folder.getId(), 0L))
));
}

private Map<Long, Long> getDeletedMaterialCountByFolderId(List<Folder> folders, Long userId) {
if (folders.isEmpty()) {
return Map.of();
}
List<Long> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FolderMaterialRestoreCountProjection> 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading