diff --git a/backend/src/main/resources/db/migration/V12__add_document_comments.sql b/backend/src/main/resources/db/migration/V12__add_document_comments.sql new file mode 100644 index 00000000..12e3b843 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_document_comments.sql @@ -0,0 +1,15 @@ +CREATE TABLE document_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE, + parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE, + author_id UUID REFERENCES users(id) ON DELETE SET NULL, + author_name VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_dc_document ON document_comments(document_id); +CREATE INDEX idx_dc_annotation ON document_comments(annotation_id); +CREATE INDEX idx_dc_parent ON document_comments(parent_id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java new file mode 100644 index 00000000..13d1906c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -0,0 +1,249 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.model.UserGroup; +import org.raddatz.familienarchiv.repository.CommentRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock CommentRepository commentRepository; + @InjectMocks CommentService commentService; + + // ─── postComment ────────────────────────────────────────────────────────── + + @Test + void postComment_capturesAuthorNameAtWriteTime() { + UUID docId = UUID.randomUUID(); + AppUser author = AppUser.builder() + .id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.postComment(docId, null, "Test", author); + + assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); + } + + @Test + void postComment_fallsBackToUsername_whenNamesAreBlank() { + UUID docId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.postComment(docId, null, "Test", author); + + assertThat(result.getAuthorName()).isEqualTo("hans42"); + } + + // ─── replyToComment ─────────────────────────────────────────────────────── + + @Test + void replyToComment_throwsNotFound_whenTargetCommentMissing() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + + verify(commentRepository, never()).save(any()); + } + + @Test + void replyToComment_resolvesToRootParent_whenReplyingToAReply() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + UUID replyId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); + DocumentComment existingReply = DocumentComment.builder() + .id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build(); + + when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply)); + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author); + + assertThat(result.getParentId()).isEqualTo(rootId); + } + + @Test + void replyToComment_usesDirectComment_whenReplyingToTopLevel() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); + + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author); + + assertThat(result.getParentId()).isEqualTo(rootId); + } + + // ─── editComment ────────────────────────────────────────────────────────── + + @Test + void editComment_throwsForbidden_whenNotAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); + + verify(commentRepository, never()).save(any()); + } + + @Test + void editComment_updatesContent_whenAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(authorId).username("hans").build(); + LocalDateTime created = LocalDateTime.now().minusMinutes(5); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(authorId) + .content("Original").authorName("Hans").createdAt(created).build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentComment result = commentService.editComment(docId, commentId, "Updated", author); + + assertThat(result.getContent()).isEqualTo("Updated"); + assertThat(result.getCreatedAt()).isEqualTo(created); + } + + // ─── deleteComment ──────────────────────────────────────────────────────── + + @Test + void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); + + verify(commentRepository, never()).delete(any()); + } + + @Test + void deleteComment_succeeds_whenAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(authorId).username("hans").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + commentService.deleteComment(docId, commentId, author); + + verify(commentRepository).delete(comment); + } + + @Test + void deleteComment_succeeds_whenAdmin() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser admin = buildAdmin(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + commentService.deleteComment(docId, commentId, admin); + + verify(commentRepository).delete(comment); + } + + // ─── getCommentsForDocument ─────────────────────────────────────────────── + + @Test + void getCommentsForDocument_returnsRootsWithRepliesAttached() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).authorName("Hans").content("Root").build(); + DocumentComment reply = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build(); + + when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId)) + .thenReturn(List.of(root)); + when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply)); + + List result = commentService.getCommentsForDocument(docId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getReplies()).containsExactly(reply); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private AppUser buildAdmin() { + return AppUser.builder() + .id(UUID.randomUUID()) + .username("admin") + .groups(Set.of(UserGroup.builder() + .id(UUID.randomUUID()) + .name("admins") + .permissions(Set.of("ADMIN")) + .build())) + .build(); + } +}