feat(alerting): InAppInboxQuery with 5s unread-count memoization
listInbox resolves user groups+roles via RbacService.getEffectiveGroupsForUser / getEffectiveRolesForUser then delegates to AlertInstanceRepository. countUnread memoized per (envId, userId) with 5s TTL via ConcurrentHashMap using a controllable Clock. 6 unit tests covering delegation, cache hit, TTL expiry, and isolation between users/envs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
* <p>
|
||||
* {@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<Key, Entry> 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.
|
||||
* <p>
|
||||
* 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<AlertInstance> listInbox(UUID envId, String userId, int limit) {
|
||||
List<String> groupIds = resolveGroupIds(userId);
|
||||
List<String> roleNames = resolveRoleNames(userId);
|
||||
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of unread (un-acked) alert instances visible to the user.
|
||||
* <p>
|
||||
* 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<String> resolveGroupIds(String userId) {
|
||||
return rbacService.getEffectiveGroupsForUser(userId)
|
||||
.stream()
|
||||
.map(g -> g.id().toString())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<String> resolveRoleNames(String userId) {
|
||||
return rbacService.getEffectiveRolesForUser(userId)
|
||||
.stream()
|
||||
.map(r -> r.name())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
* <p>
|
||||
* 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<AlertInstance> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user