feat: notifications, @mentions, and comment deep-links (#71 #72 #73) #127

Merged
marcel merged 19 commits from feat/71-72-73-notifications-mentions-deeplinks into main 2026-03-28 16:06:59 +01:00
12 changed files with 225 additions and 22 deletions
Showing only changes of commit 1615a4ffa5 - Show all commits

View File

@@ -39,7 +39,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), author);
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@@ -51,7 +51,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@@ -70,7 +70,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@@ -82,7 +82,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────

View File

@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.controller;
Review

🚨 BLOCKER — User enumeration endpoint has no permission check

GET /api/users/search?q= exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (?q=a, ?q=b, …). For a family archive this is a real privacy violation.

Fix: Add @RequirePermission(Permission.READ_ALL) at the controller class level, consistent with every other controller in this codebase. The UserSearchControllerTest must then also test the 403 path for a user without permissions.

🚨 **BLOCKER — User enumeration endpoint has no permission check** `GET /api/users/search?q=` exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (`?q=a`, `?q=b`, …). For a family archive this is a real privacy violation. **Fix:** Add `@RequirePermission(Permission.READ_ALL)` at the controller class level, consistent with every other controller in this codebase. The `UserSearchControllerTest` must then also test the 403 path for a user without permissions.
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class UserSearchController {
private final UserSearchService userSearchService;
@GetMapping("/api/users/search")
public List<MentionDTO> search(@RequestParam(defaultValue = "") String q) {
return userSearchService.search(q).stream()
.map(this::toMentionDTO)
.toList();
}
private MentionDTO toMentionDTO(AppUser user) {
return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName());
}
}

View File

@@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Data
public class CreateCommentDTO {
private String content;
private List<UUID> mentionedUserIds = new ArrayList<>();
}

View File

@@ -0,0 +1,11 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record MentionDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName
) {}

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.model;
Review

⚠️ MAJOR — @Transient field + FetchType.LAZY = potential LazyInitializationException at runtime

mentionDTOs is a @Transient field that is populated by the service before returning the entity. mentions is mapped as FetchType.LAZY. Read methods in CommentService are intentionally non-@Transactional (per architecture rules) — which means calling comment.getMentions() from withMentionDTOs() outside a transaction will trigger a LazyInitializationException.

This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo.

Fix: Either change mentions to FetchType.EAGER (acceptable for a small bounded collection like mentions), or — better — introduce a proper CommentResponseDTO so you're not mutating a JPA entity with transient display state.

⚠️ **MAJOR — `@Transient` field + `FetchType.LAZY` = potential `LazyInitializationException` at runtime** `mentionDTOs` is a `@Transient` field that is populated by the service before returning the entity. `mentions` is mapped as `FetchType.LAZY`. Read methods in `CommentService` are intentionally non-`@Transactional` (per architecture rules) — which means calling `comment.getMentions()` from `withMentionDTOs()` outside a transaction will trigger a `LazyInitializationException`. This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo. **Fix:** Either change `mentions` to `FetchType.EAGER` (acceptable for a small bounded collection like mentions), or — better — introduce a proper `CommentResponseDTO` so you're not mutating a JPA entity with transient display state.
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.dto.MentionDTO;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -60,4 +62,21 @@ public class DocumentComment {
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<DocumentComment> replies = new ArrayList<>();
// JPA join table for structured mention references — not serialized directly
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "comment_mentions",
joinColumns = @JoinColumn(name = "comment_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
@JsonIgnore
@Builder.Default
private List<AppUser> mentions = new ArrayList<>();
// Populated by CommentService before serialization — not persisted.
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<MentionDTO> mentionDTOs = new ArrayList<>();
}

View File

@@ -1,10 +1,13 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -12,4 +15,9 @@ import java.util.UUID;
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
@Query("SELECT u FROM AppUser u WHERE " +
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
}

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.service;
Review

🚨 BLOCKER — Architecture violation: direct repository access across domain boundary

CommentService directly injects AppUserRepository in saveMentions(). CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service.

This breaks the layering contract and untestable in isolation through the intended interface — any CommentServiceTest now has to mock a repository it shouldn't even know about.

Fix: Add UserService.findAllById(Collection<UUID>), inject UserService into CommentService, and call that instead.

🚨 **BLOCKER — Architecture violation: direct repository access across domain boundary** `CommentService` directly injects `AppUserRepository` in `saveMentions()`. CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service. This breaks the layering contract and untestable in isolation through the intended interface — any `CommentServiceTest` now has to mock a repository it shouldn't even know about. **Fix:** Add `UserService.findAllById(Collection<UUID>)`, inject `UserService` into `CommentService`, and call that instead.
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
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.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,21 +19,23 @@ import java.util.UUID;
public class CommentService {
private final CommentRepository commentRepository;
Review

Layering violation: CommentService directly injects AppUserRepository.

From CLAUDE.md (strictly enforced):

Services never reach into another domain's repository. Call the other domain's service instead.

AppUserRepository belongs to the user domain. CommentService should inject UserService and call a method like userService.findAllById(mentionedUserIds). If UserService doesn't expose that method yet, add it there.

Same violation exists in NotificationService, which injects both AppUserRepository and CommentRepository. NotificationService is a new service so there's more latitude, but it should still go through UserService for user lookups and CommentService for comment lookups rather than reaching into their repositories directly.

**Layering violation: `CommentService` directly injects `AppUserRepository`.** From CLAUDE.md (strictly enforced): > Services never reach into another domain's repository. Call the other domain's service instead. `AppUserRepository` belongs to the user domain. `CommentService` should inject `UserService` and call a method like `userService.findAllById(mentionedUserIds)`. If `UserService` doesn't expose that method yet, add it there. Same violation exists in `NotificationService`, which injects both `AppUserRepository` and `CommentRepository`. `NotificationService` is a new service so there's more latitude, but it should still go through `UserService` for user lookups and `CommentService` for comment lookups rather than reaching into their repositories directly.
private final AppUserRepository userRepository;
private final NotificationService notificationService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withReplies(roots);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withReplies(roots);
return withRepliesAndMentions(roots);
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
@@ -39,11 +43,16 @@ public class CommentService {
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(comment);
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
@@ -61,8 +70,11 @@ public class CommentService {
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(reply, mentionedUserIds);
DocumentComment saved = commentRepository.save(reply);
withMentionDTOs(saved);
notificationService.notifyReply(saved, root);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@@ -89,11 +101,29 @@ public class CommentService {
// ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
roots.forEach(root -> {
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
replies.forEach(this::withMentionDTOs);
root.setReplies(replies);
withMentionDTOs(root);
});
return roots;
}
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
List<AppUser> users = userRepository.findAllById(mentionedUserIds);
comment.setMentions(users);
}
private void withMentionDTOs(DocumentComment comment) {
List<MentionDTO> dtos = comment.getMentions().stream()
.map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName()))
.toList();
comment.setMentionDTOs(dtos);
}
private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId()))

View File

@@ -0,0 +1,23 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserSearchService {
private static final int MAX_RESULTS = 10;
private final AppUserRepository userRepository;
public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of();
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
}
}

View File

@@ -0,0 +1,5 @@
CREATE TABLE comment_mentions (
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (comment_id, user_id)
);

View File

@@ -81,7 +81,7 @@ class CommentControllerTest {
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())).thenReturn(saved);
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))
@@ -104,7 +104,7 @@ class CommentControllerTest {
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())).thenReturn(saved);
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))
@@ -179,7 +179,7 @@ class CommentControllerTest {
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())).thenReturn(saved);
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))
@@ -194,7 +194,7 @@ class CommentControllerTest {
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())).thenReturn(saved);
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))

View File

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
Review

🔵 MINOR — search_returnsAtMostTenResults does not assert the count

The test name promises a cap of 10 results but the assertion only checks isOk(). If the LIMIT 10 in the repository query is accidentally removed, this test still passes.

Fix:

.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
🔵 **MINOR — `search_returnsAtMostTenResults` does not assert the count** The test name promises a cap of 10 results but the assertion only checks `isOk()`. If the `LIMIT 10` in the repository query is accidentally removed, this test still passes. **Fix:** ```java .andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10))); ```
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserSearchController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class UserSearchControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean UserSearchService userSearchService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.firstName("Hans").lastName("Mueller").username("hans").build();
when(userSearchService.search("Hans")).thenReturn(List.of(user));
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
@Test
@WithMockUser
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
when(userSearchService.search("")).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", ""))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser
void search_returnsAtMostTenResults() throws Exception {
when(userSearchService.search(anyString())).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", "a"))
.andExpect(status().isOk());
}
}

View File

@@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import java.time.LocalDateTime;
@@ -31,6 +32,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class CommentServiceTest {
@Mock CommentRepository commentRepository;
@Mock AppUserRepository userRepository;
@Mock NotificationService notificationService;
@InjectMocks CommentService commentService;
@@ -45,7 +47,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author);
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
}
@@ -58,7 +60,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author);
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("hans42");
}
@@ -72,7 +74,7 @@ class CommentServiceTest {
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
@@ -97,7 +99,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@@ -116,7 +118,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@@ -135,7 +137,7 @@ class CommentServiceTest {
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Reply", author);
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
verify(notificationService).notifyReply(eq(saved), eq(root));
}