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.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -31,6 +32,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
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)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
return ResponseEntity.status(ex.getStatusCode())
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
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.NotificationType;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.NotificationService;
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
@@ -44,10 +45,12 @@ public class NotificationController {
|
|||||||
public Page<NotificationDTO> 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,
|
||||||
|
@RequestParam(required = false) NotificationType type,
|
||||||
|
@RequestParam(required = false) Boolean read,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser user = resolveUser(authentication);
|
AppUser user = resolveUser(authentication);
|
||||||
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
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")
|
@GetMapping("/api/notifications/unread-count")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Notification;
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
UUID recipientId, NotificationType type, Pageable pageable);
|
||||||
|
|
||||||
long countByRecipientIdAndReadFalse(UUID recipientId);
|
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||||
|
|
||||||
@Modifying
|
@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)
|
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||||
.map(this::toDTO);
|
.map(this::toDTO);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class NotificationControllerTest {
|
|||||||
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
|
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() 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);
|
||||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/notifications"))
|
mockMvc.perform(get("/api/notifications"))
|
||||||
@@ -78,7 +78,7 @@ class NotificationControllerTest {
|
|||||||
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith");
|
UUID.randomUUID(), null, 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(), any(), any()))
|
||||||
.thenReturn(new PageImpl<>(List.of(dto), 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"))
|
||||||
@@ -91,13 +91,36 @@ class NotificationControllerTest {
|
|||||||
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);
|
||||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/notifications"))
|
mockMvc.perform(get("/api/notifications"))
|
||||||
.andExpect(status().isOk());
|
.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 ────────────────────────────────────
|
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||||
@@ -199,7 +222,7 @@ class NotificationControllerTest {
|
|||||||
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
|
void getNotifications_returns200_whenUserHasOnlyWriteAll() 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);
|
||||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/notifications"))
|
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.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
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.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -356,6 +359,33 @@ class NotificationServiceTest {
|
|||||||
assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId);
|
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 helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||||
|
|||||||
Reference in New Issue
Block a user