diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java new file mode 100644 index 00000000..9775e04f --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java @@ -0,0 +1,93 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertInstanceRepository; +import com.cameleer.server.core.rbac.RbacService; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Server-side query helper for the in-app alert inbox. + *

+ * {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role). + * {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering + * the database on every page render. + */ +@Component +public class InAppInboxQuery { + + private static final long MEMO_TTL_MS = 5_000L; + + private final AlertInstanceRepository instanceRepo; + private final RbacService rbacService; + private final Clock clock; + + /** Cache key for the unread count memo. */ + private record Key(UUID envId, String userId) {} + + /** Cache entry: cached count + expiry timestamp. */ + private record Entry(long count, Instant expiresAt) {} + + private final ConcurrentHashMap memo = new ConcurrentHashMap<>(); + + public InAppInboxQuery(AlertInstanceRepository instanceRepo, + RbacService rbacService, + Clock alertingClock) { + this.instanceRepo = instanceRepo; + this.rbacService = rbacService; + this.clock = alertingClock; + } + + /** + * Returns the most recent {@code limit} alert instances visible to the given user. + *

+ * Visibility: the instance must target this user directly, or target a group the user belongs to, + * or target a role the user holds. Empty target lists mean "broadcast to all". + */ + public List listInbox(UUID envId, String userId, int limit) { + List groupIds = resolveGroupIds(userId); + List roleNames = resolveRoleNames(userId); + return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, limit); + } + + /** + * Returns the count of unread (un-acked) alert instances visible to the user. + *

+ * The result is memoized for 5 seconds per {@code (envId, userId)}. + */ + public long countUnread(UUID envId, String userId) { + Key key = new Key(envId, userId); + Instant now = Instant.now(clock); + Entry cached = memo.get(key); + if (cached != null && now.isBefore(cached.expiresAt())) { + return cached.count(); + } + long count = instanceRepo.countUnreadForUser(envId, userId); + memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS))); + return count; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private List resolveGroupIds(String userId) { + return rbacService.getEffectiveGroupsForUser(userId) + .stream() + .map(g -> g.id().toString()) + .toList(); + } + + private List resolveRoleNames(String userId) { + return rbacService.getEffectiveRolesForUser(userId) + .stream() + .map(r -> r.name()) + .toList(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java new file mode 100644 index 00000000..a7c8a586 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java @@ -0,0 +1,157 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertInstanceRepository; +import com.cameleer.server.core.rbac.GroupSummary; +import com.cameleer.server.core.rbac.RbacService; +import com.cameleer.server.core.rbac.RoleSummary; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit test for {@link InAppInboxQuery}. + *

+ * Uses a controllable {@link Clock} to test the 5-second memoization of + * {@link InAppInboxQuery#countUnread}. + */ +@ExtendWith(MockitoExtension.class) +class InAppInboxQueryTest { + + @Mock private AlertInstanceRepository instanceRepo; + @Mock private RbacService rbacService; + + /** Tick-able clock: each call to millis() returns the current value of this field. */ + private final AtomicLong nowMillis = new AtomicLong(1_000_000L); + + private Clock tickableClock; + private InAppInboxQuery query; + + private static final UUID ENV_ID = UUID.randomUUID(); + private static final String USER_ID = "user-123"; + + @BeforeEach + void setUp() { + // Build a Clock that delegates to the atomic counter so we can advance time precisely + tickableClock = new Clock() { + @Override public ZoneOffset getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId zone) { return this; } + @Override public Instant instant() { return Instant.ofEpochMilli(nowMillis.get()); } + }; + + query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock); + + // RbacService stubs: return no groups/roles by default. + // Lenient: countUnread tests don't invoke listInbox → stubs would otherwise be flagged unused. + lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of()); + lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of()); + } + + // ------------------------------------------------------------------------- + // listInbox + // ------------------------------------------------------------------------- + + @Test + void listInbox_delegatesWithResolvedGroupsAndRoles() { + UUID groupId = UUID.randomUUID(); + UUID roleId = UUID.randomUUID(); + when(rbacService.getEffectiveGroupsForUser(USER_ID)) + .thenReturn(List.of(new GroupSummary(groupId, "ops-group"))); + when(rbacService.getEffectiveRolesForUser(USER_ID)) + .thenReturn(List.of(new RoleSummary(roleId, "OPERATOR", true, "direct"))); + + when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of(groupId.toString())), + eq(USER_ID), eq(List.of("OPERATOR")), eq(20))) + .thenReturn(List.of()); + + List result = query.listInbox(ENV_ID, USER_ID, 20); + assertThat(result).isEmpty(); + verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()), + USER_ID, List.of("OPERATOR"), 20); + } + + // ------------------------------------------------------------------------- + // countUnread — memoization + // ------------------------------------------------------------------------- + + @Test + void countUnread_firstCallHitsRepository() { + when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(7L); + + long count = query.countUnread(ENV_ID, USER_ID); + + assertThat(count).isEqualTo(7L); + verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID); + } + + @Test + void countUnread_secondCallWithin5sUsesCache() { + when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)).thenReturn(5L); + + long first = query.countUnread(ENV_ID, USER_ID); + // Advance time by 4 seconds — still within TTL + nowMillis.addAndGet(4_000L); + long second = query.countUnread(ENV_ID, USER_ID); + + assertThat(first).isEqualTo(5L); + assertThat(second).isEqualTo(5L); + // Repository must have been called exactly once + verify(instanceRepo, times(1)).countUnreadForUser(ENV_ID, USER_ID); + } + + @Test + void countUnread_callAfter5sRefreshesCache() { + when(instanceRepo.countUnreadForUser(ENV_ID, USER_ID)) + .thenReturn(3L) // first call + .thenReturn(9L); // after cache expires + + long first = query.countUnread(ENV_ID, USER_ID); + + // Advance by exactly 5001 ms — TTL expired + nowMillis.addAndGet(5_001L); + long third = query.countUnread(ENV_ID, USER_ID); + + assertThat(first).isEqualTo(3L); + assertThat(third).isEqualTo(9L); + // Repository called twice: once on cold-miss, once after TTL expiry + verify(instanceRepo, times(2)).countUnreadForUser(ENV_ID, USER_ID); + } + + @Test + void countUnread_differentUsersDontShareCache() { + when(instanceRepo.countUnreadForUser(ENV_ID, "alice")).thenReturn(2L); + when(instanceRepo.countUnreadForUser(ENV_ID, "bob")).thenReturn(8L); + + long alice = query.countUnread(ENV_ID, "alice"); + long bob = query.countUnread(ENV_ID, "bob"); + + assertThat(alice).isEqualTo(2L); + assertThat(bob).isEqualTo(8L); + verify(instanceRepo).countUnreadForUser(ENV_ID, "alice"); + verify(instanceRepo).countUnreadForUser(ENV_ID, "bob"); + } + + @Test + void countUnread_differentEnvsDontShareCache() { + UUID envA = UUID.randomUUID(); + UUID envB = UUID.randomUUID(); + when(instanceRepo.countUnreadForUser(envA, USER_ID)).thenReturn(1L); + when(instanceRepo.countUnreadForUser(envB, USER_ID)).thenReturn(4L); + + assertThat(query.countUnread(envA, USER_ID)).isEqualTo(1L); + assertThat(query.countUnread(envB, USER_ID)).isEqualTo(4L); + } +}