feat(#71,#72,#73): SSE push notifications, mention chips, deep-link fixes
- Add SseEmitterRegistry (ConcurrentHashMap, one emitter per user) - Add GET /api/notifications/stream SSE endpoint and unread-count endpoint - Push SSE event on every notifyReply / notifyMentions via saveAndPush() - Collapse V18/V19 migrations into V16 (actor_name + annotation_id upfront) - Add @Schema(requiredMode=REQUIRED) to NotificationDTO required fields - Switch NotificationBell from polling to EventSource; seed unread count on open - Fix MentionEditor: replace setTimeout with await tick(); div role=option - Add aria-modal=true to NotificationBell dialog - Tests: SseEmitterRegistryTest (3), NotificationServiceTest (+2), NotificationControllerTest (+5) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,14 @@ package org.raddatz.familienarchiv.controller;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
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.SseEmitterRegistry;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
@@ -26,8 +29,10 @@ import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@@ -39,6 +44,7 @@ class NotificationControllerTest {
|
||||
|
||||
@MockitoBean NotificationService notificationService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean SseEmitterRegistry sseEmitterRegistry;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final UUID USER_ID = UUID.randomUUID();
|
||||
@@ -238,4 +244,63 @@ class NotificationControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notifyOnReply").value(true));
|
||||
}
|
||||
|
||||
// ─── GET /api/notifications/unread-count ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void countUnread_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void countUnread_returns200WithCount_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.countUnread(USER_ID)).thenReturn(3L);
|
||||
|
||||
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||
|
||||
// ─── GET /api/notifications/stream ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void stream_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/notifications/stream")
|
||||
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void stream_returns200_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter());
|
||||
|
||||
mockMvc.perform(get("/api/notifications/stream")
|
||||
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
UUID notifId = UUID.randomUUID();
|
||||
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||
.when(notificationService).markRead(notifId, USER_ID);
|
||||
|
||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -29,6 +30,7 @@ class NotificationServiceTest {
|
||||
@Mock NotificationRepository notificationRepository;
|
||||
@Mock UserService userService;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||
|
||||
NotificationService notificationService;
|
||||
|
||||
@@ -38,7 +40,7 @@ class NotificationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender));
|
||||
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender), sseEmitterRegistry);
|
||||
|
||||
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||
@@ -140,6 +142,34 @@ class NotificationServiceTest {
|
||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||
}
|
||||
|
||||
// ─── SSE push ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void notifyReply_pushesEventToRegistry_forEachRecipient() {
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
|
||||
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||
|
||||
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyMentions_pushesEventToRegistry_forEachMentionedUser() {
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
|
||||
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||
|
||||
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||
}
|
||||
|
||||
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
class SseEmitterRegistryTest {
|
||||
|
||||
private final SseEmitterRegistry registry = new SseEmitterRegistry();
|
||||
|
||||
@Test
|
||||
void register_returnsEmitter() {
|
||||
SseEmitter emitter = registry.register(UUID.randomUUID());
|
||||
|
||||
assertThat(emitter).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void send_doesNothing_whenNoEmitterRegistered() {
|
||||
assertThatCode(() -> registry.send(UUID.randomUUID(), "data"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_replacesExistingEmitter_forSameUser() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
|
||||
SseEmitter first = registry.register(userId);
|
||||
SseEmitter second = registry.register(userId);
|
||||
|
||||
assertThat(first).isNotSameAs(second);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user