feat(backend): add Notification entity, NotificationService, NotificationController, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Notification;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.NotificationService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(NotificationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class NotificationControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean NotificationService notificationService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final UUID USER_ID = UUID.randomUUID();
|
||||
|
||||
// ─── GET /api/notifications ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getNotifications_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
Notification n = Notification.builder()
|
||||
.id(UUID.randomUUID()).recipient(user)
|
||||
.type(NotificationType.REPLY).read(false).build();
|
||||
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
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()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(notificationService).getNotifications(eq(USER_ID), any());
|
||||
}
|
||||
|
||||
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(post("/api/notifications/read-all"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(notificationService).markAllRead(USER_ID);
|
||||
}
|
||||
|
||||
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
UUID notifId = UUID.randomUUID();
|
||||
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/users/me/notification-preferences ──────────────────────────
|
||||
|
||||
@Test
|
||||
void getPreferences_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void getPreferences_returnsCurrentPreferences() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(true).notifyOnMention(false).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||
.andExpect(jsonPath("$.notifyOnMention").value(false));
|
||||
}
|
||||
|
||||
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void updatePreferences_persistsBothBooleans() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(true).notifyOnMention(true).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
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.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Mock NotificationRepository notificationRepository;
|
||||
@Mock CommentRepository commentRepository;
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock JavaMailSender mailSender;
|
||||
|
||||
@InjectMocks NotificationService notificationService;
|
||||
|
||||
private AppUser userA;
|
||||
private AppUser userB;
|
||||
private AppUser userC;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor
|
||||
// constructor, so Mockito won't inject it automatically. Inject explicitly.
|
||||
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
|
||||
|
||||
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
|
||||
.firstName("Bob").lastName("Jones").email("b@test.com")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
|
||||
.firstName("Clara").lastName("Doe").email("c@test.com")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
}
|
||||
|
||||
// ─── notifyReply ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void notifyReply_createsNotificationForThreadParticipant() {
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
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(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);
|
||||
|
||||
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||
verify(notificationRepository, times(2)).save(captor.capture());
|
||||
|
||||
List<Notification> saved = captor.getAllValues();
|
||||
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
||||
assertThat(saved).allMatch(n -> !n.isRead());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyReply_doesNotNotifyTheReplierThemselves() {
|
||||
// userA is both a thread participant and the replier
|
||||
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, root);
|
||||
|
||||
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
|
||||
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
||||
userA.setNotifyOnReply(true);
|
||||
userB.setNotifyOnReply(false);
|
||||
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
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(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);
|
||||
|
||||
// Only userA has email enabled — one email sent
|
||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
// ─── notifyMentions ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void notifyMentions_createsNotificationPerMentionedUser() {
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||
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.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||
|
||||
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||
verify(notificationRepository, times(2)).save(captor.capture());
|
||||
|
||||
List<Notification> saved = captor.getAllValues();
|
||||
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() {
|
||||
userA.setNotifyOnMention(true);
|
||||
userB.setNotifyOnMention(false);
|
||||
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||
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.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||
|
||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
||||
Notification notification = Notification.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.recipient(userA)
|
||||
.type(NotificationType.REPLY)
|
||||
.read(false)
|
||||
.build();
|
||||
|
||||
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
|
||||
|
||||
assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("different user");
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) {
|
||||
return DocumentComment.builder()
|
||||
.id(id)
|
||||
.documentId(UUID.randomUUID())
|
||||
.parentId(parentId)
|
||||
.authorId(authorId)
|
||||
.authorName("Author")
|
||||
.content("content")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user