diff --git a/backend/src/main/java/cloudpage/Application.java b/backend/src/main/java/cloudpage/Application.java index 6123243..a6195f5 100644 --- a/backend/src/main/java/cloudpage/Application.java +++ b/backend/src/main/java/cloudpage/Application.java @@ -2,9 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @SpringBootApplication +@EnableScheduling public class Application implements WebMvcConfigurer { public static void main(String[] args) { diff --git a/backend/src/main/java/cloudpage/controller/FileController.java b/backend/src/main/java/cloudpage/controller/FileController.java index 8beb903..65130c8 100644 --- a/backend/src/main/java/cloudpage/controller/FileController.java +++ b/backend/src/main/java/cloudpage/controller/FileController.java @@ -4,6 +4,7 @@ import cloudpage.exceptions.FileNotFoundException; import cloudpage.service.FileService; import cloudpage.service.FolderService; +import cloudpage.service.TrashService; import cloudpage.service.UserService; import java.io.IOException; import java.nio.file.Files; @@ -25,6 +26,7 @@ public class FileController { private final FileService fileService; private final UserService userService; private final FolderService folderService; + private final TrashService trashService; @PostMapping("/upload") public void uploadFile(@RequestParam String folderPath, @RequestParam MultipartFile file) @@ -50,7 +52,7 @@ public ResponseEntity getFileContent(@RequestParam String path) throws I @DeleteMapping public void deleteFile(@RequestParam String filePath) throws IOException { var user = userService.getCurrentUser(); - fileService.deleteFile(user.getRootFolderPath(), filePath); + trashService.moveToTrash(user.getRootFolderPath(), user.getId(), filePath); } @PatchMapping("/move") diff --git a/backend/src/main/java/cloudpage/controller/TrashController.java b/backend/src/main/java/cloudpage/controller/TrashController.java new file mode 100644 index 0000000..5f34119 --- /dev/null +++ b/backend/src/main/java/cloudpage/controller/TrashController.java @@ -0,0 +1,41 @@ +package cloudpage.controller; + +import cloudpage.dto.TrashEntryDto; +import cloudpage.service.TrashService; +import cloudpage.service.UserService; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Endpoints for a user's trash: listing entries, restoring them, or permanently deleting them. */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/files/trash") +public class TrashController { + + private final TrashService trashService; + private final UserService userService; + + @GetMapping + public List listTrash() { + return trashService.listTrash(userService.getCurrentUser().getId()); + } + + @PostMapping("/{id}/restore") + public void restore(@PathVariable String id) throws IOException { + var user = userService.getCurrentUser(); + trashService.restore(user.getRootFolderPath(), user.getId(), id); + } + + @DeleteMapping("/{id}") + public void purge(@PathVariable String id) throws IOException { + var user = userService.getCurrentUser(); + trashService.purge(user.getRootFolderPath(), user.getId(), id); + } +} diff --git a/backend/src/main/java/cloudpage/dto/TrashEntryDto.java b/backend/src/main/java/cloudpage/dto/TrashEntryDto.java new file mode 100644 index 0000000..ce1f1dd --- /dev/null +++ b/backend/src/main/java/cloudpage/dto/TrashEntryDto.java @@ -0,0 +1,18 @@ +package cloudpage.dto; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class TrashEntryDto { + + private String id; + private String name; + private String originalPath; + private Instant deletedAt; + private long size; +} diff --git a/backend/src/main/java/cloudpage/model/TrashEntry.java b/backend/src/main/java/cloudpage/model/TrashEntry.java new file mode 100644 index 0000000..d86acd4 --- /dev/null +++ b/backend/src/main/java/cloudpage/model/TrashEntry.java @@ -0,0 +1,34 @@ +package cloudpage.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Getter; +import lombok.Setter; + +/** + * A file that has been soft-deleted into a user's trash. The {@link #id} also serves as the file + * name under the user's {@code .trash} directory. An entry is removed once its file is restored, + * permanently deleted, or purged after the retention period. + */ +@Entity +@Table(name = "trash_entries") +@Getter +@Setter +public class TrashEntry { + + @Id private String id; + + private String userId; + + /** Path, relative to the user's root, the file was deleted from and should be restored to. */ + private String originalPath; + + /** Original file name, shown in the trash listing. */ + private String displayName; + + private Instant deletedAt; + + private long sizeBytes; +} diff --git a/backend/src/main/java/cloudpage/repository/TrashEntryRepository.java b/backend/src/main/java/cloudpage/repository/TrashEntryRepository.java new file mode 100644 index 0000000..e460dc2 --- /dev/null +++ b/backend/src/main/java/cloudpage/repository/TrashEntryRepository.java @@ -0,0 +1,18 @@ +package cloudpage.repository; + +import cloudpage.model.TrashEntry; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TrashEntryRepository extends JpaRepository { + + List findByUserId(String userId); + + Optional findByIdAndUserId(String id, String userId); + + List findByDeletedAtBefore(Instant cutoff); +} diff --git a/backend/src/main/java/cloudpage/service/FolderService.java b/backend/src/main/java/cloudpage/service/FolderService.java index fa1520d..6e4e558 100644 --- a/backend/src/main/java/cloudpage/service/FolderService.java +++ b/backend/src/main/java/cloudpage/service/FolderService.java @@ -53,9 +53,11 @@ public List searchInFolder( // Locale.ROOT prevents using the system's local language for case conversion String lowerQuery = query.toLowerCase(Locale.ROOT); + Path trashDir = Paths.get(rootPath, TrashService.TRASH_DIR).normalize(); try (var stream = Files.walk(folder)) { return stream .filter(p -> !p.equals(folder)) + .filter(p -> !p.startsWith(trashDir)) .map(p -> createSearchResult(p, lowerQuery)) .filter(r -> r.getScore() >= minScore) .sorted((a, b) -> Integer.compare(b.getScore(), a.getScore())) @@ -183,8 +185,10 @@ public PageResponseDto getFolderContentPage( List items = new ArrayList<>(); try (var stream = Files.list(folder)) { + Path trashDir = Paths.get(rootPath, TrashService.TRASH_DIR).normalize(); items = stream + .filter(path -> !path.normalize().equals(trashDir)) .map( path -> { try { @@ -315,27 +319,31 @@ private FolderDto readFolderShallow(String rootPath, Path path, boolean includeC List subfolders = new ArrayList<>(); List files = new ArrayList<>(); + Path trashDir = Paths.get(rootPath, TrashService.TRASH_DIR).normalize(); try (var stream = Files.list(path)) { - stream.forEach( - childPath -> { - try { - // Resolve and validate once per child to avoid repeated toRealPath() calls. - Path childPathReal = resolvePathWithinRoot(rootReal, childPath); - - if (Files.isDirectory(childPath)) { - subfolders.add( - toFolderListItemDto(rootReal, childPath, childPathReal, includeChildCounts)); - } else if (Files.isRegularFile(childPath)) { - files.add(toFileDto(rootReal, childPath, childPathReal)); - } - } catch (IOException e) { - throw new InvalidPathException( - "Invalid path detected while reading folder: " - + childPath - + " - " - + e.getMessage()); - } - }); + stream + .filter(childPath -> !childPath.normalize().equals(trashDir)) + .forEach( + childPath -> { + try { + // Resolve and validate once per child to avoid repeated toRealPath() calls. + Path childPathReal = resolvePathWithinRoot(rootReal, childPath); + + if (Files.isDirectory(childPath)) { + subfolders.add( + toFolderListItemDto( + rootReal, childPath, childPathReal, includeChildCounts)); + } else if (Files.isRegularFile(childPath)) { + files.add(toFileDto(rootReal, childPath, childPathReal)); + } + } catch (IOException e) { + throw new InvalidPathException( + "Invalid path detected while reading folder: " + + childPath + + " - " + + e.getMessage()); + } + }); } // Calculate relative path for the current folder diff --git a/backend/src/main/java/cloudpage/service/TrashService.java b/backend/src/main/java/cloudpage/service/TrashService.java new file mode 100644 index 0000000..f69c122 --- /dev/null +++ b/backend/src/main/java/cloudpage/service/TrashService.java @@ -0,0 +1,186 @@ +package cloudpage.service; + +import cloudpage.dto.TrashEntryDto; +import cloudpage.exceptions.ResourceNotFoundException; +import cloudpage.model.TrashEntry; +import cloudpage.model.User; +import cloudpage.repository.TrashEntryRepository; +import cloudpage.repository.UserRepository; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * Implements a per-user trash (soft delete). Deleting a file moves it into a hidden {@code .trash} + * directory under the user's root and records it in the database, from where it can be listed, + * restored, or permanently removed. Entries whose retention period has elapsed are purged + * automatically on a schedule. + * + *

The trash directory lives inside the user's root so it stays within the existing path security + * boundary; it is excluded from normal folder listings. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class TrashService { + + /** Name of the per-user trash directory, relative to the user's root. */ + public static final String TRASH_DIR = ".trash"; + + private final TrashEntryRepository trashEntryRepository; + private final UserRepository userRepository; + private final FolderService folderService; + + @Value("${cloudpage.trash.retention-days:30}") + private int retentionDays; + + /** + * Moves a file into the user's trash instead of deleting it permanently. + * + * @param rootPath the root directory of the user, used as a security boundary + * @param userId the id of the owning user + * @param relativeFilePath the path, relative to the root, of the file to trash + * @throws IOException if the file cannot be moved + * @throws ResourceNotFoundException if the file does not exist or is not a regular file + */ + public void moveToTrash(String rootPath, String userId, String relativeFilePath) + throws IOException { + Path source = Paths.get(rootPath, relativeFilePath).normalize(); + folderService.validatePath(rootPath, source); + if (!Files.isRegularFile(source)) { + throw new ResourceNotFoundException("File", "FilePath", relativeFilePath); + } + + Path trashDir = Paths.get(rootPath, TRASH_DIR); + Files.createDirectories(trashDir); + + String id = UUID.randomUUID().toString(); + long size = Files.size(source); + Path target = trashDir.resolve(id); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + + TrashEntry entry = new TrashEntry(); + entry.setId(id); + entry.setUserId(userId); + entry.setOriginalPath(relativeFilePath); + entry.setDisplayName(source.getFileName().toString()); + entry.setDeletedAt(Instant.now()); + entry.setSizeBytes(size); + try { + trashEntryRepository.save(entry); + } catch (RuntimeException e) { + // Compensate: move the file back so a failed DB write does not leave an orphan in the trash. + Files.move(target, source, StandardCopyOption.REPLACE_EXISTING); + throw e; + } + } + + /** + * Lists the current contents of a user's trash. + * + * @param userId the id of the owning user + * @return the trashed entries belonging to the user + */ + public List listTrash(String userId) { + return trashEntryRepository.findByUserId(userId).stream() + .map( + entry -> + new TrashEntryDto( + entry.getId(), + entry.getDisplayName(), + entry.getOriginalPath(), + entry.getDeletedAt(), + entry.getSizeBytes())) + .toList(); + } + + /** + * Restores a trashed file back to its original location, recreating parent folders if needed. + * + * @param rootPath the root directory of the user, used as a security boundary + * @param userId the id of the owning user + * @param entryId the id of the trash entry to restore + * @throws IOException if the file cannot be moved back + * @throws ResourceNotFoundException if no matching trash entry exists for the user + */ + public void restore(String rootPath, String userId, String entryId) throws IOException { + TrashEntry entry = + trashEntryRepository + .findByIdAndUserId(entryId, userId) + .orElseThrow(() -> new ResourceNotFoundException("TrashEntry", "id", entryId)); + + Path trashed = Paths.get(rootPath, TRASH_DIR, entry.getId()).normalize(); + folderService.validatePath(rootPath, trashed); + + Path target = Paths.get(rootPath, entry.getOriginalPath()).normalize(); + folderService.validatePath(rootPath, target.getParent()); + if (Files.exists(target)) { + throw new FileAlreadyExistsException( + "Cannot restore: a file already exists at " + entry.getOriginalPath()); + } + Files.createDirectories(target.getParent()); + Files.move(trashed, target); + + trashEntryRepository.delete(entry); + } + + /** + * Permanently removes a single file from the user's trash. + * + * @param rootPath the root directory of the user, used as a security boundary + * @param userId the id of the owning user + * @param entryId the id of the trash entry to remove + * @throws IOException if the file cannot be deleted + * @throws ResourceNotFoundException if no matching trash entry exists for the user + */ + public void purge(String rootPath, String userId, String entryId) throws IOException { + TrashEntry entry = + trashEntryRepository + .findByIdAndUserId(entryId, userId) + .orElseThrow(() -> new ResourceNotFoundException("TrashEntry", "id", entryId)); + deleteTrashFile(rootPath, entry); + trashEntryRepository.delete(entry); + } + + /** + * Permanently removes every trashed file whose retention period has elapsed. Runs on a schedule + * (daily by default). + */ + @Scheduled(cron = "${cloudpage.trash.cleanup-cron:0 0 3 * * *}") + public void purgeExpired() { + Instant cutoff = Instant.now().minus(Duration.ofDays(retentionDays)); + for (TrashEntry entry : trashEntryRepository.findByDeletedAtBefore(cutoff)) { + User user = userRepository.findById(entry.getUserId()).orElse(null); + if (user == null) { + // The owning user no longer exists; drop the orphaned entry. + trashEntryRepository.delete(entry); + continue; + } + try { + deleteTrashFile(user.getRootFolderPath(), entry); + trashEntryRepository.delete(entry); + } catch (IOException e) { + // Best effort: keep the entry so it is retried on the next run. + log.warn("Failed to purge expired trash entry {}: {}", entry.getId(), e.getMessage()); + } + } + } + + private void deleteTrashFile(String rootPath, TrashEntry entry) throws IOException { + Path file = Paths.get(rootPath, TRASH_DIR, entry.getId()).normalize(); + folderService.validatePath(rootPath, file); + Files.deleteIfExists(file); + } +} diff --git a/backend/src/test/java/cloudpage/controller/FileControllerTest.java b/backend/src/test/java/cloudpage/controller/FileControllerTest.java index 8bbfc42..482c0a0 100644 --- a/backend/src/test/java/cloudpage/controller/FileControllerTest.java +++ b/backend/src/test/java/cloudpage/controller/FileControllerTest.java @@ -12,6 +12,7 @@ import cloudpage.security.JwtUtil; import cloudpage.service.FileService; import cloudpage.service.FolderService; +import cloudpage.service.TrashService; import cloudpage.service.UserService; import java.nio.file.Files; import java.nio.file.Path; @@ -35,6 +36,7 @@ class FileControllerTest { @MockitoBean private FileService fileService; @MockitoBean private UserService userService; @MockitoBean private FolderService folderService; + @MockitoBean private TrashService trashService; @MockitoBean private JwtAuthFilter jwtAuthFilter; @MockitoBean private JwtUtil jwtUtil; @@ -98,7 +100,8 @@ void getFileContent_nonExistentFile_returns404() throws Exception { void deleteFile_validRequest_returns200() throws Exception { mockMvc.perform(delete("/api/files").param("filePath", "old.txt")).andExpect(status().isOk()); - verify(fileService).deleteFile(tempDir.toString(), "old.txt"); + // delete now soft-deletes: the file is moved to the user's trash + verify(trashService).moveToTrash(tempDir.toString(), "user-1", "old.txt"); } // ── PATCH /api/files/move ──────────────────────────────────────────────── diff --git a/backend/src/test/java/cloudpage/controller/TrashControllerTest.java b/backend/src/test/java/cloudpage/controller/TrashControllerTest.java new file mode 100644 index 0000000..f29cdf0 --- /dev/null +++ b/backend/src/test/java/cloudpage/controller/TrashControllerTest.java @@ -0,0 +1,74 @@ +package cloudpage.controller; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cloudpage.dto.TrashEntryDto; +import cloudpage.model.User; +import cloudpage.security.JwtAuthFilter; +import cloudpage.security.JwtUtil; +import cloudpage.service.TrashService; +import cloudpage.service.UserService; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TrashController.class) +@AutoConfigureMockMvc(addFilters = false) +class TrashControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private TrashService trashService; + @MockitoBean private UserService userService; + @MockitoBean private JwtAuthFilter jwtAuthFilter; + @MockitoBean private JwtUtil jwtUtil; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setId("user-1"); + testUser.setUsername("testuser"); + testUser.setRootFolderPath("/root"); + when(userService.getCurrentUser()).thenReturn(testUser); + } + + @Test + void listTrash_returnsEntries() throws Exception { + when(trashService.listTrash("user-1")) + .thenReturn(List.of(new TrashEntryDto("t1", "doc.txt", "docs/doc.txt", Instant.now(), 4L))); + + mockMvc + .perform(get("/api/files/trash")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("t1")) + .andExpect(jsonPath("$[0].name").value("doc.txt")); + } + + @Test + void restore_invokesServiceWithCurrentUser() throws Exception { + mockMvc.perform(post("/api/files/trash/t1/restore")).andExpect(status().isOk()); + + verify(trashService).restore("/root", "user-1", "t1"); + } + + @Test + void purge_invokesServiceWithCurrentUser() throws Exception { + mockMvc.perform(delete("/api/files/trash/t1")).andExpect(status().isOk()); + + verify(trashService).purge("/root", "user-1", "t1"); + } +} diff --git a/backend/src/test/java/cloudpage/service/TrashServiceTest.java b/backend/src/test/java/cloudpage/service/TrashServiceTest.java new file mode 100644 index 0000000..c7e42af --- /dev/null +++ b/backend/src/test/java/cloudpage/service/TrashServiceTest.java @@ -0,0 +1,167 @@ +package cloudpage.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import cloudpage.exceptions.ResourceNotFoundException; +import cloudpage.model.TrashEntry; +import cloudpage.model.User; +import cloudpage.repository.TrashEntryRepository; +import cloudpage.repository.UserRepository; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TrashServiceTest { + + @Mock private TrashEntryRepository trashEntryRepository; + @Mock private UserRepository userRepository; + + private TrashService trashService; + + @TempDir Path tempDir; + + @BeforeEach + void setUp() { + trashService = new TrashService(trashEntryRepository, userRepository, new FolderService()); + } + + @Test + void moveToTrash_movesFileIntoTrashAndSavesEntry() throws IOException { + Files.writeString(tempDir.resolve("doc.txt"), "data"); + + trashService.moveToTrash(tempDir.toString(), "user1", "doc.txt"); + + assertFalse(Files.exists(tempDir.resolve("doc.txt")), "original file should be gone"); + Path trashDir = tempDir.resolve(TrashService.TRASH_DIR); + assertTrue(Files.isDirectory(trashDir)); + try (var entries = Files.list(trashDir)) { + assertEquals(1, entries.count(), "exactly one file should live in the trash"); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrashEntry.class); + verify(trashEntryRepository).save(captor.capture()); + TrashEntry saved = captor.getValue(); + assertEquals("user1", saved.getUserId()); + assertEquals("doc.txt", saved.getOriginalPath()); + assertEquals("doc.txt", saved.getDisplayName()); + assertEquals(4, saved.getSizeBytes()); + assertNotNull(saved.getDeletedAt()); + } + + @Test + void moveToTrash_nonExistentFile_throwsResourceNotFound() { + assertThrows( + ResourceNotFoundException.class, + () -> trashService.moveToTrash(tempDir.toString(), "user1", "ghost.txt")); + verifyNoInteractions(trashEntryRepository); + } + + @Test + void restore_movesFileBackToOriginalPathAndDeletesEntry() throws IOException { + Path trashDir = Files.createDirectory(tempDir.resolve(TrashService.TRASH_DIR)); + Files.writeString(trashDir.resolve("abc"), "restored"); + + TrashEntry entry = new TrashEntry(); + entry.setId("abc"); + entry.setUserId("user1"); + entry.setOriginalPath("sub/doc.txt"); + entry.setDisplayName("doc.txt"); + when(trashEntryRepository.findByIdAndUserId("abc", "user1")).thenReturn(Optional.of(entry)); + + trashService.restore(tempDir.toString(), "user1", "abc"); + + Path restored = tempDir.resolve("sub/doc.txt"); + assertTrue(Files.exists(restored), "file should be back at its original path"); + assertEquals("restored", Files.readString(restored)); + assertFalse(Files.exists(trashDir.resolve("abc")), "file should no longer be in the trash"); + verify(trashEntryRepository).delete(entry); + } + + @Test + void restore_unknownEntry_throwsResourceNotFound() { + when(trashEntryRepository.findByIdAndUserId(any(), any())).thenReturn(Optional.empty()); + assertThrows( + ResourceNotFoundException.class, + () -> trashService.restore(tempDir.toString(), "user1", "nope")); + } + + @Test + void restore_targetAlreadyExists_throwsAndKeepsExistingFile() throws IOException { + Path trashDir = Files.createDirectory(tempDir.resolve(TrashService.TRASH_DIR)); + Files.writeString(trashDir.resolve("abc"), "trashed"); + // a file was recreated at the original path after deletion — restore must not overwrite it + Files.writeString(tempDir.resolve("doc.txt"), "current"); + + TrashEntry entry = new TrashEntry(); + entry.setId("abc"); + entry.setUserId("user1"); + entry.setOriginalPath("doc.txt"); + when(trashEntryRepository.findByIdAndUserId("abc", "user1")).thenReturn(Optional.of(entry)); + + assertThrows( + FileAlreadyExistsException.class, + () -> trashService.restore(tempDir.toString(), "user1", "abc")); + assertEquals("current", Files.readString(tempDir.resolve("doc.txt"))); + verify(trashEntryRepository, never()).delete(any()); + } + + @Test + void purge_deletesTrashFileAndEntry() throws IOException { + Path trashDir = Files.createDirectory(tempDir.resolve(TrashService.TRASH_DIR)); + Files.writeString(trashDir.resolve("xyz"), "bye"); + + TrashEntry entry = new TrashEntry(); + entry.setId("xyz"); + entry.setUserId("user1"); + when(trashEntryRepository.findByIdAndUserId("xyz", "user1")).thenReturn(Optional.of(entry)); + + trashService.purge(tempDir.toString(), "user1", "xyz"); + + assertFalse(Files.exists(trashDir.resolve("xyz"))); + verify(trashEntryRepository).delete(entry); + } + + @Test + void purgeExpired_deletesExpiredFilesAndEntries() throws IOException { + Path trashDir = Files.createDirectory(tempDir.resolve(TrashService.TRASH_DIR)); + Files.writeString(trashDir.resolve("old"), "stale"); + + TrashEntry entry = new TrashEntry(); + entry.setId("old"); + entry.setUserId("user1"); + entry.setDeletedAt(Instant.now()); + + User user = new User(); + user.setId("user1"); + user.setRootFolderPath(tempDir.toString()); + + when(trashEntryRepository.findByDeletedAtBefore(any())).thenReturn(List.of(entry)); + when(userRepository.findById("user1")).thenReturn(Optional.of(user)); + + trashService.purgeExpired(); + + assertFalse(Files.exists(trashDir.resolve("old")), "expired trash file should be deleted"); + verify(trashEntryRepository).delete(entry); + } +}