From 46588522816c75c6251a24b4bf1d69f21023b294 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:09:30 +0200 Subject: [PATCH 1/8] fix(comment): populate annotationId on block comments from the block postBlockComment now looks up the block via TranscriptionService and sets comment.annotationId from block.getAnnotationId(). This closes the upstream root cause of issue #276, where notifications for block comments were stored with annotationId=null, breaking the notification deep-link flow on the document detail page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service/CommentService.java | 4 ++ .../service/CommentServiceTest.java | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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..dff407d2 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,6 +27,7 @@ public class CommentService { private final UserService userService; private final NotificationService notificationService; private final AuditService auditService; + private final TranscriptionService transcriptionService; public List getCommentsForDocument(UUID documentId) { List roots = @@ -46,9 +48,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) + .annotationId(block.getAnnotationId()) .content(content) .authorId(author.getId()) .authorName(resolveAuthorName(author)) 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..b72cc9fb 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,6 +40,7 @@ class CommentServiceTest { @Mock UserService userService; @Mock NotificationService notificationService; @Mock AuditService auditService; + @Mock TranscriptionService transcriptionService; @InjectMocks CommentService commentService; // ─── postComment ────────────────────────────────────────────────────────── @@ -611,6 +613,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 +647,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 +664,39 @@ 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"); + } } -- 2.49.1 From 13732ab96ba998ed13ff4ce1eeaec02c09d2505d Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:13:13 +0200 Subject: [PATCH 2/8] fix(db): V51 backfills annotation_id on block comments and notifications Previously issued block-comment notifications were stored with annotation_id=NULL because CommentService.postBlockComment did not populate DocumentComment.annotationId. Now that the code fix is in place, existing rows need to be filled in so legacy notifications can also carry the query param that the frontend deep-link flow expects. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...__backfill_block_comment_annotation_id.sql | 24 ++++ .../repository/MigrationIntegrationTest.java | 110 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V51__backfill_block_comment_annotation_id.sql 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/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; + } } -- 2.49.1 From bc69e8ff1e8d26012ebc2e71545276773a0d38a2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:27:10 +0200 Subject: [PATCH 3/8] refactor(comment): drop dead document and annotation comment APIs Only block comments are surfaced by the frontend now. The document-level and annotation-level comment endpoints and service methods existed but had no consumer. Remove them along with their repository queries and test coverage so the surface area matches the actual feature set. Shared edit, delete, and block reply endpoints stay. postBlockComment now carries the authorName/mention/audit behaviors previously tested through the dropped postComment method, so those behaviors remain covered by the block-scoped test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/CommentController.java | 61 --- .../repository/CommentRepository.java | 4 - .../service/CommentService.java | 29 -- .../controller/CommentControllerTest.java | 372 ++++++---------- .../service/CommentServiceTest.java | 403 ++++++++---------- 5 files changed, 313 insertions(+), 556 deletions(-) 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 dff407d2..554fa492 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -29,17 +29,6 @@ public class CommentService { private final AuditService auditService; private final TranscriptionService transcriptionService; - 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); - } - public List getCommentsForBlock(UUID blockId) { List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); return withRepliesAndMentions(roots); @@ -65,24 +54,6 @@ public class CommentService { return saved; } - @Transactional - public DocumentComment postComment(UUID documentId, UUID annotationId, String content, - List mentionedUserIds, AppUser author) { - DocumentComment comment = DocumentComment.builder() - .documentId(documentId) - .annotationId(annotationId) - .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 replyToComment(UUID documentId, UUID commentId, String content, List mentionedUserIds, AppUser author) { 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/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java index b72cc9fb..19eb0b8d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -43,52 +43,6 @@ class CommentServiceTest { @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 @@ -224,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); @@ -284,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 @@ -332,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 @@ -461,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() { @@ -497,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(); @@ -699,4 +476,182 @@ class CommentServiceTest { .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; + }); + } } -- 2.49.1 From 251eb9c3fc156aff6f8b05a00e136c6c98b5ebb6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:29:52 +0200 Subject: [PATCH 4/8] feat(frontend): add scrollToCommentFromQuery helper for notification deep-link Pure function that reads commentId + annotationId from the page URL, enters transcribe mode if needed, activates the block's annotation, scrolls the target comment into view, focuses it for screen readers, fires the existing annotation flash, and strips the params via the injected callback. All side effects go through callbacks so the helper is unit-testable without mounting the page or a DOM (only scrollIntoView/focus are called on the injected element). Eight tests cover both absent params, happy path, transcribe-mode activation, missing DOM target, reduced motion, flash trigger, and URL strip. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/utils/deepLinkScroll.spec.ts | 129 ++++++++++++++++++ frontend/src/lib/utils/deepLinkScroll.ts | 40 ++++++ 2 files changed, 169 insertions(+) create mode 100644 frontend/src/lib/utils/deepLinkScroll.spec.ts create mode 100644 frontend/src/lib/utils/deepLinkScroll.ts 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(); +} -- 2.49.1 From 20ae85f8791d491f342e743f002c9a58771073bd Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:32:04 +0200 Subject: [PATCH 5/8] feat(comment): expose comment id + focus ring on CommentMessage wrapper Notification deep-link scroll targets #comment-{id}. Add the id to the article wrapper along with tabindex="-1" so scrollIntoView + .focus({preventScroll:true}) can land screen-reader and keyboard focus on the specific comment. A focus-visible ring appears only for keyboard users so mouse clicks don't trigger a visible outline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/CommentMessage.svelte | 7 ++++++- .../components/CommentMessage.svelte.spec.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) 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/); + }); }); -- 2.49.1 From e22265f5bcfced229ddc38c662abec0f436554d3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 21 Apr 2026 13:42:55 +0200 Subject: [PATCH 6/8] feat(document-detail): wire notification deep-link scroll in onMount After navHeight setup, call scrollToCommentFromQuery with the page URL and callbacks into the component's local state (transcribeMode, activeAnnotationId, flashAnnotationId) plus SvelteKit's replaceState to strip the consumed query params. afterTick awaits both Svelte's tick() and one requestAnimationFrame, mirroring the existing handleAnnotationClick timing so the annotation panel has rendered before scrollIntoView fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/documents/[id]/+page.svelte | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 0d26c275..b83b7c9c 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,6 +1,8 @@