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