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:
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user