diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java index d34ee9a9..57911095 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -24,67 +24,6 @@ public class CommentController { private final CommentService commentService; private final UserService userService; - // ─── General document comments ──────────────────────────────────────────── - - @GetMapping("/api/documents/{documentId}/comments") - public List getDocumentComments(@PathVariable UUID documentId) { - return commentService.getCommentsForDocument(documentId); - } - - @PostMapping("/api/documents/{documentId}/comments") - @ResponseStatus(HttpStatus.CREATED) - @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) - public DocumentComment postDocumentComment( - @PathVariable UUID documentId, - @RequestBody CreateCommentDTO dto, - Authentication authentication) { - AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author); - } - - @PostMapping("/api/documents/{documentId}/comments/{commentId}/replies") - @ResponseStatus(HttpStatus.CREATED) - @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) - public DocumentComment replyToDocumentComment( - @PathVariable UUID documentId, - @PathVariable UUID commentId, - @RequestBody CreateCommentDTO dto, - Authentication authentication) { - AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); - } - - // ─── Annotation comments ────────────────────────────────────────────────── - - @GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments") - public List getAnnotationComments(@PathVariable UUID annotationId) { - return commentService.getCommentsForAnnotation(annotationId); - } - - @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments") - @ResponseStatus(HttpStatus.CREATED) - @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) - public DocumentComment postAnnotationComment( - @PathVariable UUID documentId, - @PathVariable UUID annotationId, - @RequestBody CreateCommentDTO dto, - Authentication authentication) { - AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author); - } - - @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies") - @ResponseStatus(HttpStatus.CREATED) - @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) - public DocumentComment replyToAnnotationComment( - @PathVariable UUID documentId, - @PathVariable UUID commentId, - @RequestBody CreateCommentDTO dto, - Authentication authentication) { - AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); - } - // ─── Block (transcription) comments ──────────────────────────────────────── @GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java index 9327a350..61e2da59 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java @@ -8,10 +8,6 @@ import java.util.UUID; public interface CommentRepository extends JpaRepository { - List findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId); - - List findByAnnotationIdAndParentIdIsNull(UUID annotationId); - List findByParentId(UUID parentId); List findByBlockIdAndParentIdIsNull(UUID blockId); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java index e6157509..554fa492 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.repository.CommentRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,17 +27,7 @@ public class CommentService { private final UserService userService; private final NotificationService notificationService; private final AuditService auditService; - - public List getCommentsForDocument(UUID documentId) { - List roots = - commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId); - return withRepliesAndMentions(roots); - } - - public List getCommentsForAnnotation(UUID annotationId) { - List roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId); - return withRepliesAndMentions(roots); - } + private final TranscriptionService transcriptionService; public List getCommentsForBlock(UUID blockId) { List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); @@ -46,27 +37,11 @@ public class CommentService { @Transactional public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content, List mentionedUserIds, AppUser author) { + TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId); DocumentComment comment = DocumentComment.builder() .documentId(documentId) .blockId(blockId) - .content(content) - .authorId(author.getId()) - .authorName(resolveAuthorName(author)) - .build(); - saveMentions(comment, mentionedUserIds); - DocumentComment saved = commentRepository.save(comment); - withMentionDTOs(saved); - notificationService.notifyMentions(mentionedUserIds, saved); - logCommentPosted(author, documentId, saved, mentionedUserIds); - return saved; - } - - @Transactional - public DocumentComment postComment(UUID documentId, UUID annotationId, String content, - List mentionedUserIds, AppUser author) { - DocumentComment comment = DocumentComment.builder() - .documentId(documentId) - .annotationId(annotationId) + .annotationId(block.getAnnotationId()) .content(content) .authorId(author.getId()) .authorName(resolveAuthorName(author)) diff --git a/backend/src/main/resources/db/migration/V51__backfill_block_comment_annotation_id.sql b/backend/src/main/resources/db/migration/V51__backfill_block_comment_annotation_id.sql new file mode 100644 index 00000000..f136adb7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V51__backfill_block_comment_annotation_id.sql @@ -0,0 +1,24 @@ +-- Backfill annotation_id on block comments and their notifications. +-- +-- Before the upstream fix, CommentService.postBlockComment did not set +-- DocumentComment.annotationId, so block comments were stored with +-- annotation_id = NULL and every notification built from them inherited +-- that NULL (see NotificationService.notifyMentions/notifyReply). +-- +-- The frontend deep-link flow needs annotationId in the URL query string +-- to open the correct annotation panel and scroll to the comment. +-- Without this backfill, previously issued notifications would still +-- carry annotation_id = NULL even after the code fix lands. + +UPDATE document_comments dc +SET annotation_id = tb.annotation_id +FROM transcription_blocks tb +WHERE dc.block_id = tb.id + AND dc.annotation_id IS NULL; + +UPDATE notifications n +SET annotation_id = dc.annotation_id +FROM document_comments dc +WHERE n.reference_id = dc.id + AND n.annotation_id IS NULL + AND dc.annotation_id IS NOT NULL; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java index 63ad617b..58714e7f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -40,246 +40,8 @@ class CommentControllerTest { private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}"; private static final UUID DOC_ID = UUID.randomUUID(); - private static final UUID ANN_ID = UUID.randomUUID(); private static final UUID COMMENT_ID = UUID.randomUUID(); - // ─── GET /api/documents/{documentId}/comments ───────────────────────────── - - @Test - void getDocumentComments_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments")) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - void getDocumentComments_returns200_whenAuthenticated() throws Exception { - when(commentService.getCommentsForDocument(any())).thenReturn(List.of()); - mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments")) - .andExpect(status().isOk()); - } - - // ─── POST /api/documents/{documentId}/comments ──────────────────────────── - - @Test - void postDocumentComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - void postDocumentComment_returns403_whenMissingPermission() throws Exception { - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(authorities = "ANNOTATE_ALL") - void postDocumentComment_returns201_whenHasPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); - when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.content").value("Test comment")); - } - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); - when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - // ─── POST /api/documents/{documentId}/comments/{commentId}/replies ──────── - - @Test - void replyToComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser(authorities = "ANNOTATE_ALL") - void replyToComment_returns201_whenHasPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID) - .authorName("Anna").content("Test comment").build(); - when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void replyToComment_returns201_whenHasWriteAllPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID) - .authorName("Anna").content("Test comment").build(); - when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - // ─── PATCH /api/documents/{documentId}/comments/{commentId} ────────────── - - @Test - void editComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser(authorities = "ANNOTATE_ALL") - void editComment_returns200_whenHasPermission() throws Exception { - DocumentComment updated = DocumentComment.builder() - .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); - when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isOk()); - } - - // ─── DELETE /api/documents/{documentId}/comments/{commentId} ───────────── - - @Test - void deleteComment_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - void deleteComment_returns204_whenAuthenticated() throws Exception { - mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) - .andExpect(status().isNoContent()); - } - - // ─── GET /api/documents/{documentId}/annotations/{annId}/comments ───────── - - @Test - void getAnnotationComments_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - void getAnnotationComments_returns200_whenAuthenticated() throws Exception { - when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of()); - mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void editComment_returns200_whenHasWriteAllPermission() throws Exception { - DocumentComment updated = DocumentComment.builder() - .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); - when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); - - mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isOk()); - } - - // ─── POST /api/documents/{documentId}/annotations/{annId}/comments ──────── - - @Test - @WithMockUser - void postAnnotationComment_returns403_whenMissingPermission() throws Exception { - mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser(authorities = "ANNOTATE_ALL") - void postAnnotationComment_returns201_whenHasPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) - .authorName("Hans").content("Test comment").build(); - when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) - .authorName("Hans").content("Test comment").build(); - when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - // ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─ - - @Test - @WithMockUser(authorities = "ANNOTATE_ALL") - void replyToAnnotationComment_returns201_whenHasPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) - .parentId(COMMENT_ID).authorName("Anna").content("Test comment").build(); - when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception { - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) - .parentId(COMMENT_ID).authorName("Anna").content("Test comment").build(); - when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - - // ─── resolveUser — exception branch ────────────────────────────────────── - - @Test - @WithMockUser(authorities = "WRITE_ALL") - void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception { - // findByEmail throws → catch block in resolveUser → author null, saves anyway - when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error")); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build(); - when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); - - mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") - .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) - .andExpect(status().isCreated()); - } - // ─── Block comment endpoints ───────────────────────────────────────────── @Test @@ -305,4 +67,138 @@ class CommentControllerTest { .andExpect(status().isCreated()) .andExpect(jsonPath("$.blockId").value(blockId.toString())); } + + @Test + void postBlockComment_returns401_whenUnauthenticated() throws Exception { + UUID blockId = UUID.randomUUID(); + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void postBlockComment_returns403_whenMissingPermission() throws Exception { + UUID blockId = UUID.randomUUID(); + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception { + UUID blockId = UUID.randomUUID(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); + when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void postBlockComment_stillSucceeds_whenUserServiceThrows() throws Exception { + // findByEmail throws → catch block in resolveUser → author null, saves anyway + UUID blockId = UUID.randomUUID(); + when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error")); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build(); + when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + // ─── Block reply endpoints ─────────────────────────────────────────────── + + @Test + void replyToBlockComment_returns401_whenUnauthenticated() throws Exception { + UUID blockId = UUID.randomUUID(); + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void replyToBlockComment_returns201_whenHasPermission() throws Exception { + UUID blockId = UUID.randomUUID(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID) + .authorName("Anna").content("Reply").build(); + when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void replyToBlockComment_returns201_whenHasWriteAllPermission() throws Exception { + UUID blockId = UUID.randomUUID(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID) + .authorName("Anna").content("Reply").build(); + when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + // ─── PATCH /api/documents/{documentId}/comments/{commentId} (shared edit) ── + + @Test + void editComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void editComment_returns200_whenHasPermission() throws Exception { + DocumentComment updated = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void editComment_returns200_whenHasWriteAllPermission() throws Exception { + DocumentComment updated = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isOk()); + } + + // ─── DELETE /api/documents/{documentId}/comments/{commentId} (shared) ──── + + @Test + void deleteComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteComment_returns204_whenAuthenticated() throws Exception { + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + .andExpect(status().isNoContent()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index 7709b486..b482a0be 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -302,6 +302,57 @@ class MigrationIntegrationTest { ).isInstanceOf(DataIntegrityViolationException.class); } + // ─── V51: backfill annotation_id on block comments and notifications ───── + + @Test + void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() { + UUID docId = createDocument(); + UUID annotationId = insertAnnotation(docId); + UUID blockId = insertBlock(docId, annotationId); + UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId); + + jdbc.update(V51_BACKFILL_COMMENTS_SQL); + + UUID stored = jdbc.queryForObject( + "SELECT annotation_id FROM document_comments WHERE id = ?", + UUID.class, commentId); + assertThat(stored).isEqualTo(annotationId); + } + + @Test + void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() { + UUID docId = createDocument(); + UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com"); + UUID annotationId = insertAnnotation(docId); + UUID blockId = insertBlock(docId, annotationId); + UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId); + UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId); + + jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL); + + UUID stored = jdbc.queryForObject( + "SELECT annotation_id FROM notifications WHERE id = ?", + UUID.class, notificationId); + assertThat(stored).isEqualTo(annotationId); + } + + private static final String V51_BACKFILL_COMMENTS_SQL = """ + UPDATE document_comments dc + SET annotation_id = tb.annotation_id + FROM transcription_blocks tb + WHERE dc.block_id = tb.id + AND dc.annotation_id IS NULL + """; + + private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """ + UPDATE notifications n + SET annotation_id = dc.annotation_id + FROM document_comments dc + WHERE n.reference_id = dc.id + AND n.annotation_id IS NULL + AND dc.annotation_id IS NOT NULL + """; + // ─── helpers ───────────────────────────────────────────────────────────── private UUID createPerson(String firstName, String lastName) { @@ -326,4 +377,63 @@ class MigrationIntegrationTest { em.flush(); return doc.getId(); } + + private UUID insertAnnotation(UUID docId) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1') + """, id, docId); + return id; + } + + private UUID insertBlock(UUID docId, UUID annotationId) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO transcription_blocks + (id, annotation_id, document_id, text, sort_order) + VALUES (?, ?, ?, '', 0) + """, id, annotationId, docId); + return id; + } + + private UUID insertUser(String email) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention) + VALUES (?, ?, 'hash', true, false, false) + """, id, email); + return id; + } + + private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO document_comments + (id, document_id, block_id, annotation_id, author_name, content) + VALUES (?, ?, ?, NULL, 'Tester', 'Hi') + """, id, docId, blockId); + return id; + } + + private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO document_comments + (id, document_id, block_id, annotation_id, author_name, content) + VALUES (?, ?, ?, ?, 'Tester', 'Hi') + """, id, docId, blockId, annotationId); + return id; + } + + private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) { + UUID id = UUID.randomUUID(); + jdbc.update(""" + INSERT INTO notifications + (id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name) + VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester') + """, id, recipientId, docId, commentId); + return 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 index a6596d80..19eb0b8d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.repository.CommentRepository; @@ -39,54 +40,9 @@ class CommentServiceTest { @Mock UserService userService; @Mock NotificationService notificationService; @Mock AuditService auditService; + @Mock TranscriptionService transcriptionService; @InjectMocks CommentService commentService; - // ─── postComment ────────────────────────────────────────────────────────── - - @Test - void postComment_capturesAuthorNameAtWriteTime() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder() - .id(UUID.randomUUID()).email("hans@example.com").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", List.of(), author); - - assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); - } - - @Test - void postComment_fallsBackToUsername_whenNamesAreBlank() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").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", List.of(), author); - - assertThat(result.getAuthorName()).isEqualTo("hans42"); - } - - @Test - void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() { - UUID docId = UUID.randomUUID(); - UUID mentionedId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); - AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build(); - - when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned)); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author); - - verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved)); - } - // ─── replyToComment ─────────────────────────────────────────────────────── @Test @@ -222,7 +178,7 @@ class CommentServiceTest { .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)); + stubSaveAssigningRandomId(); DocumentComment result = commentService.editComment(docId, commentId, "Updated", author); @@ -282,28 +238,6 @@ class CommentServiceTest { 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); - } - // ─── replyToComment — reply with null authorId in thread ───────────────── @Test @@ -330,82 +264,6 @@ class CommentServiceTest { verify(notificationService).notifyReply(eq(saved), anySet()); } - // ─── resolveAuthorName edge cases ───────────────────────────────────────── - - @Test - void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") - .firstName(" ").lastName(null).build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build(); - when(commentRepository.save(any())).thenReturn(saved); - - DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author); - - assertThat(result.getAuthorName()).isEqualTo("user42"); - } - - @Test - void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") - .firstName(null).lastName(" ").build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build(); - when(commentRepository.save(any())).thenReturn(saved); - - DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author); - - assertThat(result.getAuthorName()).isEqualTo("user42"); - } - - @Test - void postComment_includesOnlyFirstName_whenLastNameIsNull() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") - .firstName("Hans").lastName(null).build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build(); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hi", List.of(), author); - - // first != null && !blank → true; last == null → entire condition false → returns stripped first - verify(commentRepository).save(any()); - } - - @Test - void postComment_includesOnlyLastName_whenFirstNameIsNull() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") - .firstName(null).lastName("Müller").build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build(); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hi", List.of(), author); - - // No exception — name resolution with null first name strips cleanly - verify(commentRepository).save(any()); - } - - // ─── saveMentions — null/empty guard ───────────────────────────────────── - - @Test - void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com") - .firstName("Hans").lastName("M").build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build(); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hi", null, author); - - verify(userService, never()).findAllById(anyList()); - } - // ─── collectParticipantIds — non-null authorId in reply ────────────────── @Test @@ -459,26 +317,6 @@ class CommentServiceTest { verify(notificationService).notifyReply(eq(saved), anySet()); } - // ─── getCommentsForAnnotation ───────────────────────────────────────────── - - @Test - void getCommentsForAnnotation_returnsRootsForAnnotation() { - UUID annotationId = UUID.randomUUID(); - UUID rootId = UUID.randomUUID(); - - DocumentComment root = DocumentComment.builder() - .id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build(); - - when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId)) - .thenReturn(List.of(root)); - when(commentRepository.findByParentId(rootId)).thenReturn(List.of()); - - List result = commentService.getCommentsForAnnotation(annotationId); - - assertThat(result).hasSize(1); - assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId); - } - // ─── helpers ────────────────────────────────────────────────────────────── private AppUser buildAdmin() { @@ -495,65 +333,6 @@ class CommentServiceTest { // ─── audit: COMMENT_ADDED and MENTION_CREATED ───────────────────────────── - @Test - void postComment_logsCommentAdded() { - UUID docId = UUID.randomUUID(); - UUID savedId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); - DocumentComment saved = DocumentComment.builder() - .id(savedId).documentId(docId).authorName("Hans M").content("Hello").build(); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hello", List.of(), author); - - verify(auditService).logAfterCommit( - eq(AuditKind.COMMENT_ADDED), - eq(author.getId()), - eq(docId), - argThat(p -> savedId.toString().equals(p.get("commentId")))); - } - - @Test - void postComment_logsMentionCreated_oncePerMentionedUser() { - UUID docId = UUID.randomUUID(); - UUID savedId = UUID.randomUUID(); - UUID mentionedId1 = UUID.randomUUID(); - UUID mentionedId2 = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); - AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build(); - AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build(); - DocumentComment saved = DocumentComment.builder() - .id(savedId).documentId(docId).authorName("Hans M").content("Hey @Anna @Bob").build(); - when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2)); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author); - - verify(auditService).logAfterCommit( - eq(AuditKind.MENTION_CREATED), - eq(author.getId()), - eq(docId), - argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId")))); - verify(auditService).logAfterCommit( - eq(AuditKind.MENTION_CREATED), - eq(author.getId()), - eq(docId), - argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId")))); - } - - @Test - void postComment_doesNotLogMentionCreated_whenNoMentions() { - UUID docId = UUID.randomUUID(); - AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); - DocumentComment saved = DocumentComment.builder() - .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hello").build(); - when(commentRepository.save(any())).thenReturn(saved); - - commentService.postComment(docId, null, "Hello", List.of(), author); - - verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any()); - } - @Test void replyToComment_logsCommentAdded() { UUID docId = UUID.randomUUID(); @@ -611,6 +390,8 @@ class CommentServiceTest { AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build(); DocumentComment saved = DocumentComment.builder() .id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build(); + when(transcriptionService.getBlock(docId, blockId)) + .thenReturn(TranscriptionBlock.builder().id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build()); when(commentRepository.save(any())).thenReturn(saved); commentService.postBlockComment(docId, blockId, "Nice", List.of(), author); @@ -643,7 +424,10 @@ class CommentServiceTest { void postBlockComment_setsBlockIdOnComment() { UUID documentId = UUID.randomUUID(); UUID blockId = UUID.randomUUID(); + UUID annotationId = UUID.randomUUID(); AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build(); + when(transcriptionService.getBlock(documentId, blockId)) + .thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build()); when(commentRepository.save(any())).thenAnswer(inv -> { DocumentComment c = inv.getArgument(0); c.setId(UUID.randomUUID()); @@ -657,4 +441,217 @@ class CommentServiceTest { assertThat(result.getDocumentId()).isEqualTo(documentId); assertThat(result.getContent()).isEqualTo("Looks like Breslau"); } + + @Test + void postBlockComment_setsAnnotationIdFromBlock() { + UUID documentId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID annotationId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build(); + when(transcriptionService.getBlock(documentId, blockId)) + .thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build()); + when(commentRepository.save(any())).thenAnswer(inv -> { + DocumentComment c = inv.getArgument(0); + c.setId(UUID.randomUUID()); + return c; + }); + + DocumentComment result = commentService.postBlockComment( + documentId, blockId, "Nice work", List.of(), author); + + assertThat(result.getAnnotationId()).isEqualTo(annotationId); + } + + @Test + void postBlockComment_propagatesNotFound_whenBlockDoesNotExist() { + UUID documentId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build(); + when(transcriptionService.getBlock(documentId, blockId)) + .thenThrow(DomainException.notFound( + org.raddatz.familienarchiv.exception.ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, + "Transcription block not found: " + blockId)); + + assertThatThrownBy(() -> commentService.postBlockComment(documentId, blockId, "Hi", List.of(), author)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("Transcription block not found"); + } + + // ─── postBlockComment — authorName resolution ──────────────────────────── + + @Test + void postBlockComment_capturesAuthorNameAtWriteTime() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder() + .id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); + } + + @Test + void postBlockComment_fallsBackToEmail_whenNamesAreBlank() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("hans42@example.com"); + } + + @Test + void postBlockComment_fallsBackToEmail_whenFirstNameBlankAndLastNameNull() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") + .firstName(" ").lastName(null).build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("user42@example.com"); + } + + @Test + void postBlockComment_fallsBackToEmail_whenFirstNameNullAndLastNameBlank() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") + .firstName(null).lastName(" ").build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("user42@example.com"); + } + + @Test + void postBlockComment_usesFirstNameAlone_whenLastNameIsNull() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") + .firstName("Hans").lastName(null).build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("Hans"); + } + + @Test + void postBlockComment_usesLastNameAlone_whenFirstNameIsNull() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com") + .firstName(null).lastName("Müller").build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author); + + assertThat(result.getAuthorName()).isEqualTo("Müller"); + } + + // ─── postBlockComment — mentions ───────────────────────────────────────── + + @Test + void postBlockComment_triggersNotifyMentions_whenMentionedUserIdsProvided() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID mentionedId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); + AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna S").build(); + stubBlock(docId, blockId); + when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned)); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.postBlockComment(docId, blockId, "Hey @Anna S", List.of(mentionedId), author); + + verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved)); + } + + @Test + void postBlockComment_doesNotCallUserService_whenMentionedUserIdsIsNull() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com") + .firstName("Hans").lastName("M").build(); + stubBlock(docId, blockId); + stubSaveAssigningRandomId(); + + commentService.postBlockComment(docId, blockId, "Hi", null, author); + + verify(userService, never()).findAllById(anyList()); + } + + @Test + void postBlockComment_logsMentionCreated_oncePerMentionedUser() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID savedId = UUID.randomUUID(); + UUID mentionedId1 = UUID.randomUUID(); + UUID mentionedId2 = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); + AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build(); + AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build(); + DocumentComment saved = DocumentComment.builder() + .id(savedId).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna @Bob").build(); + stubBlock(docId, blockId); + when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2)); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.postBlockComment(docId, blockId, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author); + + verify(auditService).logAfterCommit( + eq(AuditKind.MENTION_CREATED), + eq(author.getId()), + eq(docId), + argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId")))); + verify(auditService).logAfterCommit( + eq(AuditKind.MENTION_CREATED), + eq(author.getId()), + eq(docId), + argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId")))); + } + + @Test + void postBlockComment_doesNotLogMentionCreated_whenNoMentions() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hello").build(); + stubBlock(docId, blockId); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.postBlockComment(docId, blockId, "Hello", List.of(), author); + + verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any()); + } + + private void stubBlock(UUID docId, UUID blockId) { + when(transcriptionService.getBlock(docId, blockId)) + .thenReturn(TranscriptionBlock.builder() + .id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build()); + } + + private void stubSaveAssigningRandomId() { + when(commentRepository.save(any())).thenAnswer(inv -> { + DocumentComment c = inv.getArgument(0); + if (c.getId() == null) c.setId(UUID.randomUUID()); + return c; + }); + } } diff --git a/frontend/e2e/notification-deep-link.spec.ts b/frontend/e2e/notification-deep-link.spec.ts new file mode 100644 index 00000000..18e24728 --- /dev/null +++ b/frontend/e2e/notification-deep-link.spec.ts @@ -0,0 +1,117 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E test for the notification deep-link scroll flow — issue #276. + * + * Seeds a document + transcription block + block comment via API, then + * visits /documents/{id}?commentId=X&annotationId=Y and verifies: + * - page enters transcribe mode + * - the target comment is visible in the viewport + * - focus lands on the comment article + * - URL query params are stripped after handling + */ + +let docHref: string; +let docId: string; +let annotationId: string; +let commentId: string; + +test.describe('Notification deep-link scroll', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Seeded line', + label: null + } + }); + if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`); + const block = await blockRes.json(); + annotationId = block.annotationId; + + const commentRes = await request.post( + `/api/documents/${docId}/transcription-blocks/${block.id}/comments`, + { + data: { content: 'Target comment for deep-link test' } + } + ); + if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`); + const comment = await commentRes.json(); + commentId = comment.id; + }); + + async function openDeepLink(page: Page) { + const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`; + await page.goto(url); + await page.waitForSelector('[data-hydrated]'); + } + + for (const viewport of [ + { width: 320, height: 700, name: 'mobile-320' }, + { width: 1440, height: 900, name: 'desktop-1440' } + ]) { + test(`deep-link scrolls comment into view at ${viewport.name}`, async ({ page }) => { + test.setTimeout(45_000); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await openDeepLink(page); + + // Transcribe mode was auto-entered — Fertig button is visible + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 }); + + // The target comment article is in the DOM and visible + const article = page.locator(`#comment-${commentId}`); + await expect(article).toBeVisible({ timeout: 10_000 }); + + // URL query params are stripped after handling + await expect.poll(() => page.url()).not.toContain('commentId='); + await expect.poll(() => page.url()).not.toContain('annotationId='); + + await page.screenshot({ + path: `test-results/e2e/notification-deep-link-${viewport.name}.png` + }); + }); + } + + test('axe accessibility check passes on document detail with deep-link', async ({ page }) => { + test.setTimeout(45_000); + await openDeepLink(page); + await expect(page.locator(`#comment-${commentId}`)).toBeVisible({ timeout: 10_000 }); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/frontend/src/lib/components/CommentMessage.svelte b/frontend/src/lib/components/CommentMessage.svelte index f1171448..f66c508c 100644 --- a/frontend/src/lib/components/CommentMessage.svelte +++ b/frontend/src/lib/components/CommentMessage.svelte @@ -32,7 +32,12 @@ const wasEdited = $derived(message.updatedAt > message.createdAt); const parsed = $derived(extractQuote(message.content)); -
+
{ await expect.element(textarea).toBeInTheDocument(); await expect.element(textarea).toHaveValue('current edit text'); }); + + it('exposes id="comment-{message.id}" on the article wrapper for deep-link scroll', async () => { + render(CommentMessage, defaultProps()); + const article = page.getByRole('article').element(); + expect(article.getAttribute('id')).toBe('comment-msg-1'); + }); + + it('is focusable but not in tab order (tabindex="-1")', async () => { + render(CommentMessage, defaultProps()); + const article = page.getByRole('article').element(); + expect(article.getAttribute('tabindex')).toBe('-1'); + }); + + it('shows a focus-visible ring when focused via keyboard', async () => { + render(CommentMessage, defaultProps()); + const article = page.getByRole('article').element(); + const classes = article.className; + expect(classes).toMatch(/focus-visible:ring-2/); + expect(classes).toMatch(/focus-visible:ring-brand-navy/); + expect(classes).toMatch(/outline-none/); + }); }); diff --git a/frontend/src/lib/utils/deepLinkScroll.spec.ts b/frontend/src/lib/utils/deepLinkScroll.spec.ts new file mode 100644 index 00000000..20f0cf19 --- /dev/null +++ b/frontend/src/lib/utils/deepLinkScroll.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; +import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll'; + +const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111'; +const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222'; + +function fakeElement() { + return { + scrollIntoView: vi.fn(), + focus: vi.fn() + } as unknown as HTMLElement; +} + +type Overrides = Partial; + +function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions { + const el = overrides.getElement ? null : fakeElement(); + return { + transcribeMode: true, + setTranscribeMode: vi.fn(), + loadBlocks: vi.fn().mockResolvedValue(undefined), + setActiveAnnotationId: vi.fn(), + flashAnnotation: vi.fn(), + prefersReducedMotion: false, + afterTick: vi.fn().mockResolvedValue(undefined), + getElement: vi.fn().mockReturnValue(el), + onStripUrl: vi.fn(), + ...overrides + }; +} + +describe('scrollToCommentFromQuery', () => { + it('is a no-op when commentId query param is absent', async () => { + const url = new URL('https://app/documents/doc-1'); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setActiveAnnotationId).not.toHaveBeenCalled(); + expect(opts.getElement).not.toHaveBeenCalled(); + expect(opts.onStripUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when annotationId query param is absent even if commentId is present', async () => { + const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setActiveAnnotationId).not.toHaveBeenCalled(); + expect(opts.getElement).not.toHaveBeenCalled(); + }); + + it('scrolls to the comment element and focuses it when both params are present', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`); + expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }); + expect(el.focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('triggers the annotation flash after scrolling', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID); + }); + + it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts({ transcribeMode: false }); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.setTranscribeMode).toHaveBeenCalledWith(true); + expect(opts.loadBlocks).toHaveBeenCalled(); + }); + + it('is a graceful no-op when the target element is not in the DOM', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) }); + + // Must not throw. Flash should not fire — nothing to highlight. + await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined(); + + expect(opts.flashAnnotation).not.toHaveBeenCalled(); + }); + + it('uses behavior "instant" when prefers-reduced-motion is set', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const el = fakeElement(); + const opts = buildOpts({ + prefersReducedMotion: true, + getElement: vi.fn().mockReturnValue(el) + }); + + await scrollToCommentFromQuery(url, opts); + + expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' }); + }); + + it('strips both commentId and annotationId from the URL after handling', async () => { + const url = new URL( + `https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}` + ); + const opts = buildOpts(); + + await scrollToCommentFromQuery(url, opts); + + expect(opts.onStripUrl).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/utils/deepLinkScroll.ts b/frontend/src/lib/utils/deepLinkScroll.ts new file mode 100644 index 00000000..cc0d49c9 --- /dev/null +++ b/frontend/src/lib/utils/deepLinkScroll.ts @@ -0,0 +1,40 @@ +export type DeepLinkScrollOptions = { + transcribeMode: boolean; + setTranscribeMode: (value: boolean) => void; + loadBlocks: () => Promise; + setActiveAnnotationId: (id: string) => void; + flashAnnotation: (annotationId: string) => void; + prefersReducedMotion: boolean; + afterTick: () => Promise; + getElement: (id: string) => HTMLElement | null; + onStripUrl: () => void; +}; + +export async function scrollToCommentFromQuery( + url: URL, + opts: DeepLinkScrollOptions +): Promise { + const commentId = url.searchParams.get('commentId'); + if (!commentId) return; + + const annotationId = url.searchParams.get('annotationId'); + if (!annotationId) return; + + if (!opts.transcribeMode) { + opts.setTranscribeMode(true); + await opts.loadBlocks(); + } + + opts.setActiveAnnotationId(annotationId); + await opts.afterTick(); + + const el = opts.getElement(`comment-${commentId}`); + if (el) { + const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth'; + el.scrollIntoView({ behavior, block: 'center' }); + el.focus({ preventScroll: true }); + opts.flashAnnotation(annotationId); + } + + opts.onStripUrl(); +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 0d26c275..c0e67381 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,6 +1,8 @@