fix(#71-#73): address all review findings from Markus and Sara

BLOCKERs:
- Remove direct AppUserRepository/CommentRepository access from CommentService and
  NotificationService — replaced with UserService.findAllById() and UserService
  (fixes layering contract from CLAUDE.md)
- Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false)
  field and ReflectionTestUtils hack in tests
- Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration
  without read access

Data bug:
- Promote actorName from @Transient to persisted VARCHAR column (V18 migration)
- Set actorName in notifyReply and notifyMentions from comment.getAuthorName()

Architecture:
- Add @RequirePermission(READ_ALL) to NotificationController
- Introduce NotificationDTO — controller returns DTO instead of Notification entity,
  eliminating lazy-load N+1 and AppUser field leakage
- Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction
- Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a
  notification failure cannot roll back the parent comment
- N+1 fix: replace per-ID findById loops with single findAllById bulk fetch
- Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly

Security:
- Escape displayName before injecting into renderBody HTML span
- Replace <a href="#"> with <span class="mention"> — no profile page to link to, and
  the anchor's scroll-to-top behaviour is harmful

Tests added/fixed:
- markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository
- markOneRead_returns401, @RequirePermission 403 coverage for both controllers
- postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
- search_returnsAtMostTenResults now asserts $.length() <= 10
- XSS regression test for escaped displayName in mention.spec.ts

Frontend minors:
- relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded)
- aria-label uses m.notification_unread() Paraglide key (de/en/es added)
- <div role="button"> replaced with <button> (native Enter+Space handling)
- onDestroy clears debounceTimer in MentionEditor
- setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread
- Notification prefs form uses checkbox name attributes + formData.has() pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-28 00:31:38 +01:00
parent 2bc3b3fb6c
commit dc6ea080c4
24 changed files with 293 additions and 166 deletions

View File

@@ -1,9 +1,11 @@
package org.raddatz.familienarchiv.controller; package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO; import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Notification; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.NotificationService; import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -17,13 +19,14 @@ import java.util.UUID;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequirePermission(Permission.READ_ALL)
public class NotificationController { public class NotificationController {
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserService userService; private final UserService userService;
@GetMapping("/api/notifications") @GetMapping("/api/notifications")
public Page<Notification> getNotifications( public Page<NotificationDTO> getNotifications(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
Authentication authentication) { Authentication authentication) {
@@ -40,7 +43,7 @@ public class NotificationController {
} }
@PatchMapping("/api/notifications/{id}/read") @PatchMapping("/api/notifications/{id}/read")
public Notification markOneRead( public NotificationDTO markOneRead(
@PathVariable UUID id, @PathVariable UUID id,
Authentication authentication) { Authentication authentication) {
AppUser user = resolveUser(authentication); AppUser user = resolveUser(authentication);

View File

@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO; import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.UserSearchService; import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -12,6 +14,7 @@ import java.util.List;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequirePermission(Permission.READ_ALL)
public class UserSearchController { public class UserSearchController {
private final UserSearchService userSearchService; private final UserSearchService userSearchService;

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.dto;
import org.raddatz.familienarchiv.model.NotificationType;
import java.time.LocalDateTime;
import java.util.UUID;
public record NotificationDTO(
UUID id,
NotificationType type,
UUID documentId,
UUID referenceId,
boolean read,
LocalDateTime createdAt,
String actorName
) {}

View File

@@ -64,7 +64,7 @@ public class DocumentComment {
private List<DocumentComment> replies = new ArrayList<>(); private List<DocumentComment> replies = new ArrayList<>();
// JPA join table for structured mention references — not serialized directly // JPA join table for structured mention references — not serialized directly
@ManyToMany(fetch = FetchType.LAZY) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable( @JoinTable(
name = "comment_mentions", name = "comment_mentions",
joinColumns = @JoinColumn(name = "comment_id"), joinColumns = @JoinColumn(name = "comment_id"),

View File

@@ -46,8 +46,7 @@ public class Notification {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt; private LocalDateTime createdAt;
// Populated by NotificationService before serialization — not persisted. @Column(name = "actor_name")
@Transient
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String actorName; private String actorName;
} }

View File

@@ -8,7 +8,6 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface NotificationRepository extends JpaRepository<Notification, UUID> { public interface NotificationRepository extends JpaRepository<Notification, UUID> {
@@ -20,6 +19,4 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
@Modifying @Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId") @Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
void markAllReadByRecipientId(@Param("userId") UUID userId); void markAllReadByRecipientId(@Param("userId") UUID userId);
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
} }

View File

@@ -6,12 +6,13 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment; import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository; import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -19,7 +20,7 @@ import java.util.UUID;
public class CommentService { public class CommentService {
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final AppUserRepository userRepository; private final UserService userService;
private final NotificationService notificationService; private final NotificationService notificationService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) { public List<DocumentComment> getCommentsForDocument(UUID documentId) {
@@ -73,7 +74,10 @@ public class CommentService {
saveMentions(reply, mentionedUserIds); saveMentions(reply, mentionedUserIds);
DocumentComment saved = commentRepository.save(reply); DocumentComment saved = commentRepository.save(reply);
withMentionDTOs(saved); withMentionDTOs(saved);
notificationService.notifyReply(saved, root);
Set<UUID> participantIds = collectParticipantIds(root);
participantIds.remove(author.getId());
notificationService.notifyReply(saved, participantIds);
notificationService.notifyMentions(mentionedUserIds, saved); notificationService.notifyMentions(mentionedUserIds, saved);
return saved; return saved;
} }
@@ -99,6 +103,10 @@ public class CommentService {
commentRepository.delete(comment); commentRepository.delete(comment);
} }
public List<DocumentComment> findReplies(UUID parentId) {
return commentRepository.findByParentId(parentId);
}
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) { private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
@@ -113,7 +121,7 @@ public class CommentService {
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) { private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return; if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
List<AppUser> users = userRepository.findAllById(mentionedUserIds); List<AppUser> users = userService.findAllById(mentionedUserIds);
comment.setMentions(users); comment.setMentions(users);
} }
@@ -124,6 +132,16 @@ public class CommentService {
comment.setMentionDTOs(dtos); comment.setMentionDTOs(dtos);
} }
private Set<UUID> collectParticipantIds(DocumentComment root) {
Set<UUID> ids = new LinkedHashSet<>();
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
commentRepository.findByParentId(root.getId())
.forEach(reply -> {
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
});
return ids;
}
private DocumentComment findComment(UUID documentId, UUID commentId) { private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId) return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId())) .filter(c -> documentId.equals(c.getDocumentId()))

View File

@@ -2,16 +2,14 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment; import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.Notification; import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType; import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.raddatz.familienarchiv.repository.NotificationRepository; import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -19,9 +17,9 @@ import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -33,11 +31,8 @@ import java.util.UUID;
public class NotificationService { public class NotificationService {
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final CommentRepository commentRepository; private final UserService userService;
private final AppUserRepository userRepository; private final Optional<JavaMailSender> mailSender;
@Autowired(required = false)
private JavaMailSender mailSender;
@Value("${app.mail.from:noreply@familienarchiv.local}") @Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom; private String mailFrom;
@@ -46,24 +41,21 @@ public class NotificationService {
private String baseUrl; private String baseUrl;
/** /**
* Creates REPLY notifications for all participants in the thread that the given reply belongs to, * Creates REPLY notifications for all participants in the thread, excluding the replier.
* excluding the replier themselves. * Runs in a separate transaction so a notification failure cannot roll back the parent comment.
*/ */
@Transactional @Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyReply(DocumentComment reply, DocumentComment root) { public void notifyReply(DocumentComment reply, Set<UUID> participantIds) {
Set<UUID> participantIds = collectParticipantIds(root); if (participantIds.isEmpty()) return;
participantIds.remove(reply.getAuthorId());
for (UUID participantId : participantIds) { List<AppUser> recipients = userService.findAllById(participantIds);
Optional<AppUser> recipientOpt = userRepository.findById(participantId); for (AppUser recipient : recipients) {
if (recipientOpt.isEmpty()) continue;
AppUser recipient = recipientOpt.get();
Notification notification = Notification.builder() Notification notification = Notification.builder()
.recipient(recipient) .recipient(recipient)
.type(NotificationType.REPLY) .type(NotificationType.REPLY)
.documentId(reply.getDocumentId()) .documentId(reply.getDocumentId())
.referenceId(reply.getId()) .referenceId(reply.getId())
.actorName(reply.getAuthorName())
.build(); .build();
notificationRepository.save(notification); notificationRepository.save(notification);
@@ -75,19 +67,20 @@ public class NotificationService {
/** /**
* Creates MENTION notifications for each mentioned user. * Creates MENTION notifications for each mentioned user.
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
*/ */
@Transactional @Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) { public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
for (UUID mentionedUserId : mentionedUserIds) { if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
if (recipientOpt.isEmpty()) continue;
AppUser recipient = recipientOpt.get(); List<AppUser> recipients = userService.findAllById(mentionedUserIds);
for (AppUser recipient : recipients) {
Notification notification = Notification.builder() Notification notification = Notification.builder()
.recipient(recipient) .recipient(recipient)
.type(NotificationType.MENTION) .type(NotificationType.MENTION)
.documentId(comment.getDocumentId()) .documentId(comment.getDocumentId())
.referenceId(comment.getId()) .referenceId(comment.getId())
.actorName(comment.getAuthorName())
.build(); .build();
notificationRepository.save(notification); notificationRepository.save(notification);
@@ -97,8 +90,9 @@ public class NotificationService {
} }
} }
public Page<Notification> getNotifications(UUID userId, Pageable pageable) { public Page<NotificationDTO> getNotifications(UUID userId, Pageable pageable) {
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable); return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toDTO);
} }
public long countUnread(UUID userId) { public long countUnread(UUID userId) {
@@ -111,7 +105,7 @@ public class NotificationService {
} }
@Transactional @Transactional
public Notification markRead(UUID notificationId, UUID userId) { public NotificationDTO markRead(UUID notificationId, UUID userId) {
Notification notification = notificationRepository.findById(notificationId) Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId)); ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
@@ -119,29 +113,26 @@ public class NotificationService {
throw DomainException.forbidden("Notification belongs to a different user"); throw DomainException.forbidden("Notification belongs to a different user");
} }
notification.setRead(true); notification.setRead(true);
return notificationRepository.save(notification); return toDTO(notificationRepository.save(notification));
} }
@Transactional @Transactional
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) { public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
AppUser user = userRepository.findById(userId) return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention);
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId));
user.setNotifyOnReply(notifyOnReply);
user.setNotifyOnMention(notifyOnMention);
return userRepository.save(user);
} }
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private Set<UUID> collectParticipantIds(DocumentComment root) { private NotificationDTO toDTO(Notification n) {
Set<UUID> ids = new LinkedHashSet<>(); return new NotificationDTO(
if (root.getAuthorId() != null) ids.add(root.getAuthorId()); n.getId(),
n.getType(),
commentRepository.findByParentId(root.getId()) n.getDocumentId(),
.forEach(reply -> { n.getReferenceId(),
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId()); n.isRead(),
}); n.getCreatedAt(),
return ids; n.getActorName()
);
} }
private void buildCommentPath(DocumentComment comment, StringBuilder sb) { private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
@@ -152,7 +143,7 @@ public class NotificationService {
} }
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) { private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
if (mailSender == null) { if (mailSender.isEmpty()) {
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail()); log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
return; return;
} }
@@ -179,7 +170,7 @@ public class NotificationService {
message.setText(body); message.setText(body);
try { try {
mailSender.send(message); mailSender.get().send(message);
} catch (MailException e) { } catch (MailException e) {
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage()); log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
} }

View File

@@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -78,6 +79,18 @@ public class UserService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
} }
public List<AppUser> findAllById(Collection<UUID> ids) {
return userRepository.findAllById(ids);
}
@Transactional
public AppUser updateNotificationPreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
AppUser user = getById(userId);
user.setNotifyOnReply(notifyOnReply);
user.setNotifyOnMention(notifyOnMention);
return userRepository.save(user);
}
@Transactional @Transactional
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) { public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
AppUser user = getById(userId); AppUser user = getById(userId);

View File

@@ -0,0 +1 @@
ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255);

View File

@@ -2,8 +2,8 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType; import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
@@ -20,6 +20,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -52,15 +53,22 @@ class NotificationControllerTest {
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser")
void getNotifications_returns403_whenUserLacksPermission() throws Exception {
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returns200WithList_whenAuthenticated() throws Exception { void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
Notification n = Notification.builder() NotificationDTO dto = new NotificationDTO(
.id(UUID.randomUUID()).recipient(user) UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
.type(NotificationType.REPLY).read(false).build(); UUID.randomUUID(), false, LocalDateTime.now(), "Anna Smith");
when(userService.findByUsername("testuser")).thenReturn(user); when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any())) when(notificationService.getNotifications(eq(USER_ID), any()))
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1)); .thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
mockMvc.perform(get("/api/notifications")) mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -68,7 +76,7 @@ class NotificationControllerTest {
} }
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception { void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user); when(userService.findByUsername("testuser")).thenReturn(user);
@@ -90,7 +98,7 @@ class NotificationControllerTest {
} }
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markAllRead_returns204_whenAuthenticated() throws Exception { void markAllRead_returns204_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user); when(userService.findByUsername("testuser")).thenReturn(user);
@@ -104,7 +112,13 @@ class NotificationControllerTest {
// ─── PATCH /api/notifications/{id}/read ────────────────────────────────── // ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
@Test @Test
@WithMockUser(username = "testuser") void markOneRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception { void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
UUID notifId = UUID.randomUUID(); UUID notifId = UUID.randomUUID();
@@ -127,7 +141,7 @@ class NotificationControllerTest {
} }
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getPreferences_returnsCurrentPreferences() throws Exception { void getPreferences_returnsCurrentPreferences() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser") AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(false).build(); .notifyOnReply(true).notifyOnMention(false).build();
@@ -142,7 +156,7 @@ class NotificationControllerTest {
// ─── PUT /api/users/me/notification-preferences ────────────────────────── // ─── PUT /api/users/me/notification-preferences ──────────────────────────
@Test @Test
@WithMockUser(username = "testuser") @WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void updatePreferences_persistsBothBooleans() throws Exception { void updatePreferences_persistsBothBooleans() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser") AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(false).build(); .notifyOnReply(false).notifyOnMention(false).build();

View File

@@ -16,7 +16,9 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.stream.IntStream;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -40,6 +42,13 @@ class UserSearchControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_returns403_whenUserLacksPermission() throws Exception {
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = {"READ_ALL"})
void search_returns200_whenAuthenticated() throws Exception { void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()) AppUser user = AppUser.builder().id(UUID.randomUUID())
.firstName("Hans").lastName("Mueller").username("hans").build(); .firstName("Hans").lastName("Mueller").username("hans").build();
@@ -51,7 +60,7 @@ class UserSearchControllerTest {
} }
@Test @Test
@WithMockUser @WithMockUser(authorities = {"READ_ALL"})
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception { void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
when(userSearchService.search("")).thenReturn(List.of()); when(userSearchService.search("")).thenReturn(List.of());
@@ -61,11 +70,16 @@ class UserSearchControllerTest {
} }
@Test @Test
@WithMockUser @WithMockUser(authorities = {"READ_ALL"})
void search_returnsAtMostTenResults() throws Exception { void search_returnsAtMostTenResults() throws Exception {
when(userSearchService.search(anyString())).thenReturn(List.of()); List<AppUser> elevenUsers = IntStream.range(0, 11)
.mapToObj(i -> AppUser.builder().id(UUID.randomUUID())
.firstName("User").lastName(String.valueOf(i)).username("u" + i).build())
.toList();
when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10));
mockMvc.perform(get("/api/users/search").param("q", "a")) mockMvc.perform(get("/api/users/search").param("q", "a"))
.andExpect(status().isOk()); .andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
} }
} }

View File

@@ -9,7 +9,6 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment; import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository; import org.raddatz.familienarchiv.repository.CommentRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -21,6 +20,8 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -32,7 +33,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class CommentServiceTest { class CommentServiceTest {
@Mock CommentRepository commentRepository; @Mock CommentRepository commentRepository;
@Mock AppUserRepository userRepository; @Mock UserService userService;
@Mock NotificationService notificationService; @Mock NotificationService notificationService;
@InjectMocks CommentService commentService; @InjectMocks CommentService commentService;
@@ -65,6 +66,23 @@ class CommentServiceTest {
assertThat(result.getAuthorName()).isEqualTo("hans42"); 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()).username("hans").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").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 ─────────────────────────────────────────────────────── // ─── replyToComment ───────────────────────────────────────────────────────
@Test @Test
@@ -95,6 +113,7 @@ class CommentServiceTest {
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply)); when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build(); .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
@@ -114,6 +133,7 @@ class CommentServiceTest {
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
@@ -124,7 +144,7 @@ class CommentServiceTest {
} }
@Test @Test
void replyToComment_triggersNotification_afterSave() { void replyToComment_triggersNotifyReply_afterSave() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID(); UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
@@ -135,11 +155,35 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Reply", List.of(), author); commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
verify(notificationService).notifyReply(eq(saved), eq(root)); verify(notificationService).notifyReply(eq(saved), anySet());
}
@Test
void replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Hey @Bob J").authorName("anna").build();
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Hey @Bob J", List.of(mentionedId), author);
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
} }
// ─── editComment ────────────────────────────────────────────────────────── // ─── editComment ──────────────────────────────────────────────────────────

View File

@@ -4,20 +4,18 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.*; import org.raddatz.familienarchiv.model.*;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.raddatz.familienarchiv.repository.NotificationRepository; import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -29,11 +27,10 @@ import static org.mockito.Mockito.*;
class NotificationServiceTest { class NotificationServiceTest {
@Mock NotificationRepository notificationRepository; @Mock NotificationRepository notificationRepository;
@Mock CommentRepository commentRepository; @Mock UserService userService;
@Mock AppUserRepository userRepository;
@Mock JavaMailSender mailSender; @Mock JavaMailSender mailSender;
@InjectMocks NotificationService notificationService; NotificationService notificationService;
private AppUser userA; private AppUser userA;
private AppUser userB; private AppUser userB;
@@ -41,9 +38,7 @@ class NotificationServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender));
// constructor, so Mockito won't inject it automatically. Inject explicitly.
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
userA = AppUser.builder().id(UUID.randomUUID()).username("userA") userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
.firstName("Anna").lastName("Smith").email("a@test.com") .firstName("Anna").lastName("Smith").email("a@test.com")
@@ -59,17 +54,13 @@ class NotificationServiceTest {
// ─── notifyReply ────────────────────────────────────────────────────────── // ─── notifyReply ──────────────────────────────────────────────────────────
@Test @Test
void notifyReply_createsNotificationForThreadParticipant() { void notifyReply_createsNotificationForThreadParticipants() {
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply)); when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root); notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class); ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(2)).save(captor.capture()); verify(notificationRepository, times(2)).save(captor.capture());
@@ -79,57 +70,30 @@ class NotificationServiceTest {
.containsExactlyInAnyOrder(userA.getId(), userB.getId()); .containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY); assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
assertThat(saved).allMatch(n -> !n.isRead()); assertThat(saved).allMatch(n -> !n.isRead());
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
} }
@Test @Test
void notifyReply_doesNotNotifyTheReplierThemselves() { void notifyReply_doesNothing_whenParticipantSetIsEmpty() {
// userA is both a thread participant and the replier DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply)); notificationService.notifyReply(reply, Set.of());
notificationService.notifyReply(reply, root);
verify(notificationRepository, never()).save(any()); verify(notificationRepository, never()).save(any());
} }
@Test
void notifyReply_deduplicatesParticipants() {
// userB has posted twice in the thread — should get exactly one notification
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root);
// userA (root) + userB (deduplicated) = 2 notifications, not 3
verify(notificationRepository, times(2)).save(any());
}
@Test @Test
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() { void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
userA.setNotifyOnReply(true); userA.setNotifyOnReply(true);
userB.setNotifyOnReply(false); userB.setNotifyOnReply(false);
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply)); when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root); notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
// Only userA has email enabled — one email sent
verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
} }
@@ -137,9 +101,8 @@ class NotificationServiceTest {
@Test @Test
void notifyMentions_createsNotificationPerMentionedUser() { void notifyMentions_createsNotificationPerMentionedUser() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId()); DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment); notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
@@ -151,6 +114,16 @@ class NotificationServiceTest {
assertThat(saved).extracting(n -> n.getRecipient().getId()) assertThat(saved).extracting(n -> n.getRecipient().getId())
.containsExactlyInAnyOrder(userA.getId(), userB.getId()); .containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION); assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
}
@Test
void notifyMentions_doesNothing_whenListIsEmpty() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
notificationService.notifyMentions(List.of(), comment);
verify(notificationRepository, never()).save(any());
} }
@Test @Test
@@ -158,9 +131,8 @@ class NotificationServiceTest {
userA.setNotifyOnMention(true); userA.setNotifyOnMention(true);
userB.setNotifyOnMention(false); userB.setNotifyOnMention(false);
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId()); DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment); notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
@@ -170,6 +142,16 @@ class NotificationServiceTest {
// ─── markRead ───────────────────────────────────────────────────────────── // ─── markRead ─────────────────────────────────────────────────────────────
@Test
void markRead_throwsNotFound_whenNotificationDoesNotExist() {
UUID notifId = UUID.randomUUID();
when(notificationRepository.findById(notifId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> notificationService.markRead(notifId, userA.getId()))
.isInstanceOf(DomainException.class)
.hasMessageContaining("Notification not found");
}
@Test @Test
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() { void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
Notification notification = Notification.builder() Notification notification = Notification.builder()
@@ -186,15 +168,33 @@ class NotificationServiceTest {
.hasMessageContaining("different user"); .hasMessageContaining("different user");
} }
// ─── markAllRead ──────────────────────────────────────────────────────────
@Test
void markAllRead_delegatesToRepository() {
notificationService.markAllRead(userA.getId());
verify(notificationRepository).markAllReadByRecipientId(userA.getId());
}
// ─── countUnread ──────────────────────────────────────────────────────────
@Test
void countUnread_delegatesToRepository() {
when(notificationRepository.countByRecipientIdAndReadFalse(userA.getId())).thenReturn(3L);
assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L);
}
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) { private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
return DocumentComment.builder() return DocumentComment.builder()
.id(id) .id(id)
.documentId(UUID.randomUUID()) .documentId(UUID.randomUUID())
.parentId(parentId) .parentId(parentId)
.authorId(authorId) .authorId(authorId)
.authorName("Author") .authorName(authorName)
.content("content") .content("content")
.build(); .build();
} }

View File

@@ -304,6 +304,7 @@
"notification_prefs_heading": "Benachrichtigungen", "notification_prefs_heading": "Benachrichtigungen",
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet", "notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt", "notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen", "mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden" "mention_popup_empty": "Keine Nutzer gefunden"
} }

View File

@@ -304,6 +304,7 @@
"notification_prefs_heading": "Notifications", "notification_prefs_heading": "Notifications",
"notification_pref_reply": "Email when someone replies to my comment", "notification_pref_reply": "Email when someone replies to my comment",
"notification_pref_mention": "Email when someone mentions me in a comment", "notification_pref_mention": "Email when someone mentions me in a comment",
"notification_unread": "unread",
"mention_btn_label": "Mention person", "mention_btn_label": "Mention person",
"mention_popup_empty": "No users found" "mention_popup_empty": "No users found"
} }

View File

@@ -304,6 +304,7 @@
"notification_prefs_heading": "Notificaciones", "notification_prefs_heading": "Notificaciones",
"notification_pref_reply": "Correo cuando alguien responde a mi comentario", "notification_pref_reply": "Correo cuando alguien responde a mi comentario",
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario", "notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona", "mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios" "mention_popup_empty": "No se encontraron usuarios"
} }

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, tick, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types'; import type { Comment, CommentReply } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte'; import MentionEditor from '$lib/components/MentionEditor.svelte';
@@ -180,7 +180,7 @@ function cancelReply() {
replyText = ''; replyText = '';
} }
onMount(() => { onMount(async () => {
if (loadOnMount) { if (loadOnMount) {
reload(); reload();
} else { } else {
@@ -189,11 +189,11 @@ onMount(() => {
} }
if (targetCommentId) { if (targetCommentId) {
// Scroll to target after a tick so the DOM is settled await tick();
setTimeout(() => { requestAnimationFrame(() => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100); });
// Remove highlight on first user interaction // Remove highlight on first user interaction
const clearHighlight = () => { const clearHighlight = () => {

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
import { detectMention } from '$lib/utils/mention'; import { detectMention } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types'; import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
@@ -180,6 +181,8 @@ function handleAtButtonClick() {
}, 0); }, 0);
} }
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null); const popupOpen = $derived(query !== null);
</script> </script>

View File

@@ -117,16 +117,15 @@ function attachClickOutside(node: HTMLElement) {
} }
function relativeTime(isoString: string): string { function relativeTime(isoString: string): string {
const now = Date.now(); const diffMs = Date.now() - new Date(isoString).getTime();
const then = new Date(isoString).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000); const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben'; const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
if (diffMin < 60) return `vor ${diffMin} Min.`; if (diffMin < 1) return rtf.format(0, 'minute');
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
const diffH = Math.floor(diffMin / 60); const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `vor ${diffH} Std.`; if (diffH < 24) return rtf.format(-diffH, 'hour');
const diffD = Math.floor(diffH / 24); const diffD = Math.floor(diffH / 24);
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`; return rtf.format(-diffD, 'day');
} }
onMount(() => { onMount(() => {
@@ -232,12 +231,10 @@ onDestroy(() => {
<ul role="list"> <ul role="list">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<div <button
role="button" type="button"
tabindex="0"
onclick={() => markRead(notification)} onclick={() => markRead(notification)}
onkeydown={(e) => e.key === 'Enter' && markRead(notification)} class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}" {!notification.read ? 'bg-accent-bg/20' : ''}"
> >
<!-- Type icon --> <!-- Type icon -->
@@ -291,10 +288,10 @@ onDestroy(() => {
{#if !notification.read} {#if !notification.read}
<span <span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label="ungelesen" aria-label={m.notification_unread()}
></span> ></span>
{/if} {/if}
</div> </button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -92,10 +92,10 @@ describe('renderBody', () => {
expect(result).toContain('AT&amp;T'); expect(result).toContain('AT&amp;T');
}); });
it('wraps @mention in an anchor tag', () => { it('wraps @mention in a mention span', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Hey @Hans Müller!', mentions); const result = renderBody('Hey @Hans Müller!', mentions);
expect(result).toContain('<a'); expect(result).toContain('<span');
expect(result).toContain('Hans Müller'); expect(result).toContain('Hans Müller');
}); });
@@ -108,8 +108,15 @@ describe('renderBody', () => {
it('replaces all occurrences of the same mention', () => { it('replaces all occurrences of the same mention', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('@Hans Müller and @Hans Müller', mentions); const result = renderBody('@Hans Müller and @Hans Müller', mentions);
const linkCount = (result.match(/<a /g) ?? []).length; const spanCount = (result.match(/<span /g) ?? []).length;
expect(linkCount).toBe(2); expect(spanCount).toBe(2);
});
it('escapes HTML special chars in mention display names', () => {
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
const result = renderBody('@<script> alert(1)', mentions);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
}); });
it('converts newlines to <br>', () => { it('converts newlines to <br>', () => {

View File

@@ -59,8 +59,13 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
for (const mention of mentions) { for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim(); const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`; const escapedDisplayName = displayName
escaped = escaped.replaceAll(`@${displayName}`, link); .replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
} }
return escaped.replaceAll('\n', '<br>'); return escaped.replaceAll('\n', '<br>');

View File

@@ -60,8 +60,8 @@ export const actions: Actions = {
updateNotificationPrefs: async ({ request, fetch }) => { updateNotificationPrefs: async ({ request, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const body = { const body = {
notifyOnReply: formData.get('notifyOnReply') === 'true', notifyOnReply: formData.has('notifyOnReply'),
notifyOnMention: formData.get('notifyOnMention') === 'true' notifyOnMention: formData.has('notifyOnMention')
}; };
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, { const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {

View File

@@ -54,13 +54,11 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
{/if} {/if}
<form method="POST" action="?/updateNotificationPrefs" use:enhance> <form method="POST" action="?/updateNotificationPrefs" use:enhance>
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
<div class="space-y-4"> <div class="space-y-4">
<label class="flex cursor-pointer items-start gap-3"> <label class="flex cursor-pointer items-start gap-3">
<input <input
type="checkbox" type="checkbox"
name="notifyOnReply"
bind:checked={notifyOnReply} bind:checked={notifyOnReply}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary" class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/> />
@@ -70,6 +68,7 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
<label class="flex cursor-pointer items-start gap-3"> <label class="flex cursor-pointer items-start gap-3">
<input <input
type="checkbox" type="checkbox"
name="notifyOnMention"
bind:checked={notifyOnMention} bind:checked={notifyOnMention}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary" class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/> />