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:
hsiegeln
2026-04-19 20:25:00 +02:00
parent 6b48bc63bf
commit d3dd8882bd
2 changed files with 250 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}