From 304359f67d449fe5852c332f14f15bab82d45bf4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 00:16:04 +0100 Subject: [PATCH] feat(#145): add type and read filter params to GET /api/notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard widget uses ?type=MENTION&read=false to fetch unread mentions. Also adds MethodArgumentTypeMismatchException → 400 handler so invalid enum values in any @RequestParam return 400 instead of 500. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/GlobalExceptionHandler.java | 7 ++ .../controller/NotificationController.java | 5 +- .../repository/NotificationRepository.java | 4 + .../service/NotificationService.java | 6 +- .../NotificationControllerTest.java | 33 +++++- .../NotificationRepositoryTest.java | 101 ++++++++++++++++++ .../service/NotificationServiceTest.java | 30 ++++++ 7 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java index cd2b37d3..afeb052a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.server.ResponseStatusException; import lombok.extern.slf4j.Slf4j; @@ -31,6 +32,12 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'"; + return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity handleResponseStatus(ResponseStatusException ex) { return ResponseEntity.status(ex.getStatusCode()) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java index cbdfc354..61bef6c6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.dto.NotificationDTO; import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO; import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.NotificationType; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.NotificationService; @@ -44,10 +45,12 @@ public class NotificationController { public Page getNotifications( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) NotificationType type, + @RequestParam(required = false) Boolean read, Authentication authentication) { AppUser user = resolveUser(authentication); PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - return notificationService.getNotifications(user.getId(), pageable); + return notificationService.getNotifications(user.getId(), type, read, pageable); } @GetMapping("/api/notifications/unread-count") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java index da161912..7de51cd8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.repository; import org.raddatz.familienarchiv.model.Notification; +import org.raddatz.familienarchiv.model.NotificationType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,6 +15,9 @@ public interface NotificationRepository extends JpaRepository findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + Page findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + UUID recipientId, NotificationType type, Pageable pageable); + long countByRecipientIdAndReadFalse(UUID recipientId); @Modifying diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java index 7e7a6f65..6df48bf8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java @@ -93,7 +93,11 @@ public class NotificationService { } } - public Page getNotifications(UUID userId, Pageable pageable) { + public Page getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) { + if (type != null && Boolean.FALSE.equals(read)) { + return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable) + .map(this::toDTO); + } return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable) .map(this::toDTO); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java index f0ab0859..d7cf088b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java @@ -62,7 +62,7 @@ class NotificationControllerTest { void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception { AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); when(userService.findByUsername("testuser")).thenReturn(user); - when(notificationService.getNotifications(eq(USER_ID), any())) + when(notificationService.getNotifications(eq(USER_ID), any(), any(), any())) .thenReturn(new PageImpl<>(List.of())); mockMvc.perform(get("/api/notifications")) @@ -78,7 +78,7 @@ class NotificationControllerTest { UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith"); when(userService.findByUsername("testuser")).thenReturn(user); - when(notificationService.getNotifications(eq(USER_ID), any())) + when(notificationService.getNotifications(eq(USER_ID), any(), any(), any())) .thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1)); mockMvc.perform(get("/api/notifications")) @@ -91,13 +91,36 @@ class NotificationControllerTest { void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception { AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); when(userService.findByUsername("testuser")).thenReturn(user); - when(notificationService.getNotifications(eq(USER_ID), any())) + when(notificationService.getNotifications(eq(USER_ID), any(), any(), any())) .thenReturn(new PageImpl<>(List.of())); mockMvc.perform(get("/api/notifications")) .andExpect(status().isOk()); - verify(notificationService).getNotifications(eq(USER_ID), any()); + verify(notificationService).getNotifications(eq(USER_ID), any(), any(), any()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNotifications_withTypeAndReadFalse_passesFiltersToService() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + when(userService.findByUsername("testuser")).thenReturn(user); + when(notificationService.getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any())) + .thenReturn(new PageImpl<>(List.of())); + + mockMvc.perform(get("/api/notifications") + .param("type", "MENTION") + .param("read", "false")) + .andExpect(status().isOk()); + + verify(notificationService).getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNotifications_withInvalidType_returns400() throws Exception { + mockMvc.perform(get("/api/notifications").param("type", "INVALID_TYPE")) + .andExpect(status().isBadRequest()); } // ─── POST /api/notifications/read-all ──────────────────────────────────── @@ -199,7 +222,7 @@ class NotificationControllerTest { void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception { AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); when(userService.findByUsername("testuser")).thenReturn(user); - when(notificationService.getNotifications(eq(USER_ID), any())) + when(notificationService.getNotifications(eq(USER_ID), any(), any(), any())) .thenReturn(new PageImpl<>(List.of())); mockMvc.perform(get("/api/notifications")) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java new file mode 100644 index 00000000..c64ba2db --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/NotificationRepositoryTest.java @@ -0,0 +1,101 @@ +package org.raddatz.familienarchiv.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Notification; +import org.raddatz.familienarchiv.model.NotificationType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class NotificationRepositoryTest { + + @Autowired NotificationRepository notificationRepository; + @Autowired AppUserRepository appUserRepository; + + private AppUser userA; + private AppUser userB; + + @BeforeEach + void setUp() { + notificationRepository.deleteAll(); + appUserRepository.deleteAll(); + userA = appUserRepository.save(AppUser.builder().username("userA").password("pw").build()); + userB = appUserRepository.save(AppUser.builder().username("userB").password("pw").build()); + } + + // ─── findByRecipientIdAndTypeAndReadFalse ───────────────────────────────── + + @Test + void returnsOnlyUnreadMentions_forTargetUser() { + notificationRepository.save(mention(userA, false)); // ✓ match + notificationRepository.save(mention(userA, true)); // read — excluded + notificationRepository.save(reply(userA, false)); // REPLY — excluded + notificationRepository.save(mention(userB, false)); // different user — excluded + + Page result = notificationRepository + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + userA.getId(), NotificationType.MENTION, Pageable.ofSize(10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getRecipient().getId()).isEqualTo(userA.getId()); + assertThat(result.getContent().get(0).getType()).isEqualTo(NotificationType.MENTION); + assertThat(result.getContent().get(0).isRead()).isFalse(); + } + + @Test + void returnsEmpty_whenAllMentionsAreRead() { + notificationRepository.save(mention(userA, true)); + + Page result = notificationRepository + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + userA.getId(), NotificationType.MENTION, Pageable.ofSize(10)); + + assertThat(result.getContent()).isEmpty(); + } + + @Test + void respectsSizeLimit() { + for (int i = 0; i < 5; i++) { + notificationRepository.save(mention(userA, false)); + } + + Page result = notificationRepository + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + userA.getId(), NotificationType.MENTION, Pageable.ofSize(3)); + + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(5); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private Notification mention(AppUser recipient, boolean read) { + return Notification.builder() + .recipient(recipient) + .type(NotificationType.MENTION) + .actorName("Tester") + .read(read) + .build(); + } + + private Notification reply(AppUser recipient, boolean read) { + return Notification.builder() + .recipient(recipient) + .type(NotificationType.REPLY) + .actorName("Tester") + .read(read) + .build(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java index 1977584b..28badf84 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -15,6 +15,9 @@ import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; import java.util.Set; @@ -356,6 +359,33 @@ class NotificationServiceTest { assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId); } + // ─── getNotifications — filter dispatch ────────────────────────────────── + + @Test + void getNotifications_withNoFilters_usesUnfilteredRepoMethod() { + when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10)); + + verify(notificationRepository).findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()); + verify(notificationRepository, never()) + .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); + } + + @Test + void getNotifications_withTypeAndReadFalse_usesFilteredRepoMethod() { + when(notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), NotificationType.MENTION, false, Pageable.ofSize(3)); + + verify(notificationRepository).findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( + eq(userA.getId()), eq(NotificationType.MENTION), any()); + verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any()); + } + // ─── private helpers ────────────────────────────────────────────────────── private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {