feat(#145): add type and read filter params to GET /api/notifications

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 00:16:04 +01:00
parent bf46fe6d8b
commit 304359f67d
7 changed files with 179 additions and 7 deletions

View File

@@ -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<ErrorResponse> 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<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())

View File

@@ -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<NotificationDTO> 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")

View File

@@ -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<Notification, UUID
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);
@Modifying

View File

@@ -93,7 +93,11 @@ public class NotificationService {
}
}
public Page<NotificationDTO> getNotifications(UUID userId, Pageable pageable) {
public Page<NotificationDTO> 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);
}

View File

@@ -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"))

View File

@@ -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<Notification> 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<Notification> 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<Notification> 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();
}
}

View File

@@ -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) {