From 580703a9be7313e127628147fcf828dd0bda3de1 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Sat, 29 Aug 2026 20:33:32 +0200 Subject: [PATCH] fix: allow account deletion with non-archived notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account deletion reused the single-note delete path, which enforces the "only archived notes may be deleted" rule and aborted the whole transaction on the first active note. Account deletion now uses a dedicated bulk path that removes all of the user's notes (archived or not), note URLs, and tags, while the single-note endpoint keeps the trash-can rule unchanged. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- .../server/repository/TagRepository.java | 4 + .../server/service/NoteService.java | 23 +++ .../server/service/UserSessionService.java | 21 ++- .../service/AccountDeletionIntTest.java | 155 ++++++++++++++++++ .../server/service/NoteServiceTest.java | 22 +++ .../service/UserSessionServiceTest.java | 14 +- 6 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 server/src/test/java/br/com/tasknoteapp/server/service/AccountDeletionIntTest.java diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java index 187ffa5..a999d41 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java @@ -24,4 +24,8 @@ and not exists (select 1 from TaskEntity tk where t member of tk.tags) and not exists (select 1 from NoteEntity n where t member of n.tags) """) void deleteOrphanedTags(@Param("userId") Long userId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from TagEntity t where t.user.id = :userId") + void deleteAllForUser(@Param("userId") Long userId); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java index ba4416e..0feaa4b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java @@ -196,6 +196,29 @@ public NoteResponse patchNote(Long noteId, NotePatchRequest patch) { return NoteResponse.fromEntity(patchedNote, getNoteUrl(patchedNote.getId())); } + /** + * Delete all notes and their URLs for the current user, regardless of archived state. + * + *

This is intended for account deletion, where the trash-can rule (only archived notes may be + * deleted) does not apply. + */ + @Transactional + public void deleteAllNotesForCurrentUser() { + UserEntity user = getCurrentUser(); + + logger.info("Deleting all notes for user ID {}", user.getId()); + + List notes = noteRepository.findAllByUser_id(user.getId()); + for (NoteEntity note : notes) { + noteUrlRepository.deleteByNote_id(note.getId()); + noteRepository.delete(note); + } + + tagRepository.deleteOrphanedTags(user.getId()); + + logger.info("All {} notes deleted for user ID {}", notes.size(), user.getId()); + } + /** * Delete a note and all its URLs, if any, for the user. * diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/UserSessionService.java b/server/src/main/java/br/com/tasknoteapp/server/service/UserSessionService.java index 6172550..22ca5d1 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/UserSessionService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/UserSessionService.java @@ -2,8 +2,8 @@ import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.exception.UserNotFoundException; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.response.JwtAuthenticationResponse; -import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.response.UserResponse; import br.com.tasknoteapp.server.util.SecurityUtil; @@ -26,18 +26,25 @@ public class UserSessionService { private final NoteService noteService; + private final TagRepository tagRepository; + /** * Constructor for UserSessionService. * * @param authService the authentication service * @param taskService the task service * @param noteService the note service + * @param tagRepository the tag repository */ public UserSessionService( - AuthService authService, TaskService taskService, NoteService noteService) { + AuthService authService, + TaskService taskService, + NoteService noteService, + TagRepository tagRepository) { this.authService = authService; this.taskService = taskService; this.noteService = noteService; + this.tagRepository = tagRepository; } /** @@ -75,13 +82,9 @@ public UserResponse deleteCurrentUserAccount() { } } - List notes = noteService.getAllNotes(); - for (NoteResponse note : notes) { - Long noteId = note != null ? note.id() : null; - if (noteId != null) { - noteService.deleteNote(noteId); - } - } + noteService.deleteAllNotesForCurrentUser(); + + tagRepository.deleteAllForUser(userOptional.get().getId()); UserResponse response = authService.deleteUserAccount(); logger.info("User account deleted for user ID {}", userOptional.get().getId()); diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AccountDeletionIntTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AccountDeletionIntTest.java new file mode 100644 index 0000000..5910270 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AccountDeletionIntTest.java @@ -0,0 +1,155 @@ +package br.com.tasknoteapp.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import br.com.tasknoteapp.server.entity.NoteEntity; +import br.com.tasknoteapp.server.entity.NoteUrlEntity; +import br.com.tasknoteapp.server.entity.TagEntity; +import br.com.tasknoteapp.server.entity.TaskEntity; +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.exception.NoteArchivedException; +import br.com.tasknoteapp.server.repository.NoteRepository; +import br.com.tasknoteapp.server.repository.NoteUrlRepository; +import br.com.tasknoteapp.server.repository.TagRepository; +import br.com.tasknoteapp.server.repository.TaskRepository; +import br.com.tasknoteapp.server.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class AccountDeletionIntTest { + + @Autowired private UserSessionService userSessionService; + + @Autowired private NoteService noteService; + + @Autowired private UserRepository userRepository; + + @Autowired private NoteRepository noteRepository; + + @Autowired private NoteUrlRepository noteUrlRepository; + + @Autowired private TaskRepository taskRepository; + + @Autowired private TagRepository tagRepository; + + private UserEntity user; + + @BeforeEach + void setUp() { + user = new UserEntity(); + user.setEmail("account-deletion@domain.com"); + user.setPassword("a1b2c3d4f5g6"); + user.setAdmin(false); + user.setCreatedAt(LocalDateTime.now()); + user.setLastPasswordChange(LocalDateTime.now()); + user = userRepository.save(user); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + new UsernamePasswordAuthenticationToken(user.getEmail(), null, List.of())); + SecurityContextHolder.setContext(context); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("Delete account with mixed archived and non-archived notes should remove all data") + void deleteAccount_mixedArchivedNotes_shouldRemoveAllUserData() { + TagEntity tag = tagRepository.save(new TagEntity("work", user)); + + NoteEntity activeNote = new NoteEntity(); + activeNote.setTitle("Active note"); + activeNote.setDescription("Not archived"); + activeNote.setUser(user); + activeNote.setLastUpdate(LocalDateTime.now()); + activeNote.setTags(Set.of(tag)); + activeNote = noteRepository.save(activeNote); + + NoteUrlEntity noteUrl = new NoteUrlEntity(); + noteUrl.setUrl("http://example.com"); + noteUrl.setNote(activeNote); + noteUrlRepository.save(noteUrl); + + NoteEntity archivedNote = new NoteEntity(); + archivedNote.setTitle("Archived note"); + archivedNote.setDescription("Archived"); + archivedNote.setUser(user); + archivedNote.setLastUpdate(LocalDateTime.now()); + archivedNote.setArchived(true); + archivedNote.setTags(Set.of(tag)); + archivedNote = noteRepository.save(archivedNote); + + TaskEntity task = new TaskEntity(); + task.setDescription("A task"); + task.setCompleted(false); + task.setUser(user); + task.setLastUpdate(LocalDateTime.now()); + task.setTags(Set.of(tag)); + taskRepository.save(task); + + Long userId = user.getId(); + final Long activeNoteId = activeNote.getId(); + + userSessionService.deleteCurrentUserAccount(); + + assertTrue(userRepository.findById(userId).isEmpty()); + assertTrue(noteRepository.findAllByUser_id(userId).isEmpty()); + assertTrue(noteUrlRepository.findByNote_id(activeNoteId).isEmpty()); + assertTrue(taskRepository.findAllByUser_id(userId).isEmpty()); + assertTrue(tagRepository.findAllByUser_idOrderByNameAsc(userId).isEmpty()); + } + + @Test + @DisplayName("Single-note delete should still reject non-archived notes") + void deleteNote_nonArchived_shouldStillThrow() { + NoteEntity activeNote = new NoteEntity(); + activeNote.setTitle("Active note"); + activeNote.setDescription("Not archived"); + activeNote.setUser(user); + activeNote.setLastUpdate(LocalDateTime.now()); + activeNote = noteRepository.save(activeNote); + + Long noteId = activeNote.getId(); + + assertThrows(NoteArchivedException.class, () -> noteService.deleteNote(noteId)); + + assertTrue(noteRepository.findById(noteId).isPresent()); + } + + @Test + @DisplayName("Single-note delete of archived note should still work") + void deleteNote_archived_shouldSucceed() { + NoteEntity archivedNote = new NoteEntity(); + archivedNote.setTitle("Archived note"); + archivedNote.setDescription("Archived"); + archivedNote.setUser(user); + archivedNote.setLastUpdate(LocalDateTime.now()); + archivedNote.setArchived(true); + archivedNote = noteRepository.save(archivedNote); + + Long noteId = archivedNote.getId(); + + noteService.deleteNote(noteId); + + assertTrue(noteRepository.findById(noteId).isEmpty()); + assertEquals(0, noteRepository.findAllByUser_id(user.getId()).size()); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java index 3694392..5d3c549 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java @@ -162,6 +162,28 @@ void deleteNote() { verify(noteRepository, times(1)).delete(note); } + @Test + void deleteAllNotesForCurrentUser() { + NoteEntity archivedNote = new NoteEntity(); + archivedNote.setId(2L); + archivedNote.setTitle("Archived Note"); + archivedNote.setDescription("Archived Description"); + archivedNote.setUser(user); + archivedNote.setArchived(true); + + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail())); + when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user)); + when(noteRepository.findAllByUser_id(user.getId())).thenReturn(List.of(note, archivedNote)); + + noteService.deleteAllNotesForCurrentUser(); + + verify(noteUrlRepository, times(1)).deleteByNote_id(note.getId()); + verify(noteUrlRepository, times(1)).deleteByNote_id(archivedNote.getId()); + verify(noteRepository, times(1)).delete(note); + verify(noteRepository, times(1)).delete(archivedNote); + verify(tagRepository, times(1)).deleteOrphanedTags(user.getId()); + } + @Test void searchNotes() { when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail())); diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java index ef66a7c..80b7862 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/UserSessionServiceTest.java @@ -7,7 +7,7 @@ import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.exception.UserNotFoundException; -import br.com.tasknoteapp.server.response.NoteResponse; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.response.UserResponse; import java.util.List; @@ -26,12 +26,14 @@ class UserSessionServiceTest { @Mock private AuthService authService; @Mock private TaskService taskService; @Mock private NoteService noteService; + @Mock private TagRepository tagRepository; private UserSessionService userSessionService; @BeforeEach void setUp() { - userSessionService = new UserSessionService(authService, taskService, noteService); + userSessionService = + new UserSessionService(authService, taskService, noteService, tagRepository); } @Test @@ -43,12 +45,9 @@ void deleteCurrentUserAccount_happyPath_shouldSucceed() { TaskResponse task = new TaskResponse(1L, false, "Task 1", true, null, null, null, null, List.of()); - NoteResponse note = - new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null, false); when(authService.getCurrentUser()).thenReturn(Optional.of(user)); when(taskService.getAllTasks()).thenReturn(List.of(task)); - when(noteService.getAllNotes()).thenReturn(List.of(note)); when(authService.deleteUserAccount()) .thenReturn( new UserResponse( @@ -61,7 +60,8 @@ void deleteCurrentUserAccount_happyPath_shouldSucceed() { assert response.userId() == 1L; assert response.email().equals("user@domain.com"); verify(taskService).deleteTask(task.id()); - verify(noteService).deleteNote(note.id()); + verify(noteService).deleteAllNotesForCurrentUser(); + verify(tagRepository).deleteAllForUser(user.getId()); verify(authService).deleteUserAccount(); } @@ -72,6 +72,6 @@ void deleteCurrentUserAccount_userNotFound_shouldThrowException() { // Act & Assert assertThrows(UserNotFoundException.class, () -> userSessionService.deleteCurrentUserAccount()); - verifyNoInteractions(taskService, noteService); + verifyNoInteractions(taskService, noteService, tagRepository); } }