feat(alerting): Postgres repository for alert_instances with inbox queries
Implements AlertInstanceRepository: save (upsert), findById, findOpenForRule, listForInbox (3-way OR: user/group/role via && array-overlap + ANY), countUnreadForUser (LEFT JOIN alert_reads), ack, resolve, markSilenced, deleteResolvedBefore. Integration test covers all 9 scenarios including inbox fan-out across all three target types. Also adds @JsonIgnoreProperties(ignoreUnknown=true) to SilenceMatcher to suppress Jackson serializing isWildcard() as a round-trip field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package com.cameleer.server.app.alerting.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.alerting.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
private PostgresAlertInstanceRepository repo;
|
||||
private UUID envId;
|
||||
private UUID ruleId;
|
||||
private final String userId = "inbox-user-" + UUID.randomUUID();
|
||||
private final String groupId = UUID.randomUUID().toString();
|
||||
private final String roleName = "OPERATOR";
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
repo = new PostgresAlertInstanceRepository(jdbcTemplate, new ObjectMapper());
|
||||
envId = UUID.randomUUID();
|
||||
ruleId = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
envId, "test-env-" + UUID.randomUUID(), "Test Env");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
userId, userId + "@example.com");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email) VALUES ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
|
||||
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
|
||||
"VALUES (?, ?, 'rule', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
|
||||
ruleId, envId);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
|
||||
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " +
|
||||
"(SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndFindByIdRoundtrip() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
var found = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(found.id()).isEqualTo(inst.id());
|
||||
assertThat(found.state()).isEqualTo(AlertState.FIRING);
|
||||
assertThat(found.severity()).isEqualTo(AlertSeverity.WARNING);
|
||||
assertThat(found.targetUserIds()).containsExactly(userId);
|
||||
assertThat(found.targetGroupIds()).isEmpty();
|
||||
assertThat(found.targetRoleNames()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listForInbox_seesAllThreeTargetTypes() {
|
||||
// Instance 1 — targeted at user directly
|
||||
var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
// Instance 2 — targeted at group
|
||||
var byGroup = newInstance(ruleId, List.of(), List.of(UUID.fromString(groupId)), List.of());
|
||||
// Instance 3 — targeted at role
|
||||
var byRole = newInstance(ruleId, List.of(), List.of(), List.of(roleName));
|
||||
|
||||
repo.save(byUser);
|
||||
repo.save(byGroup);
|
||||
repo.save(byRole);
|
||||
|
||||
// User is member of the group AND has the role
|
||||
var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), 50);
|
||||
assertThat(inbox).extracting(AlertInstance::id)
|
||||
.containsExactlyInAnyOrder(byUser.id(), byGroup.id(), byRole.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listForInbox_emptyGroupsAndRoles() {
|
||||
var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(byUser);
|
||||
|
||||
var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), 50);
|
||||
assertThat(inbox).hasSize(1);
|
||||
assertThat(inbox.get(0).id()).isEqualTo(byUser.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countUnreadForUser_decreasesAfterMarkRead() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
long before = repo.countUnreadForUser(envId, userId);
|
||||
assertThat(before).isEqualTo(1L);
|
||||
|
||||
// Insert read record directly (AlertReadRepository not yet wired in this test)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
||||
userId, inst.id());
|
||||
|
||||
long after = repo.countUnreadForUser(envId, userId);
|
||||
assertThat(after).isEqualTo(0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOpenForRule_excludesResolved() {
|
||||
var open = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(open);
|
||||
|
||||
assertThat(repo.findOpenForRule(ruleId)).isPresent();
|
||||
|
||||
repo.resolve(open.id(), Instant.now());
|
||||
|
||||
assertThat(repo.findOpenForRule(ruleId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ack_setsAckedAtAndState() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
Instant when = Instant.now();
|
||||
repo.ack(inst.id(), userId, when);
|
||||
|
||||
var found = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(found.state()).isEqualTo(AlertState.ACKNOWLEDGED);
|
||||
assertThat(found.ackedBy()).isEqualTo(userId);
|
||||
assertThat(found.ackedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_setsResolvedAtAndState() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
repo.resolve(inst.id(), Instant.now());
|
||||
|
||||
var found = repo.findById(inst.id()).orElseThrow();
|
||||
assertThat(found.state()).isEqualTo(AlertState.RESOLVED);
|
||||
assertThat(found.resolvedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteResolvedBefore_deletesOnlyResolved() {
|
||||
var firing = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
var resolved = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(firing);
|
||||
repo.save(resolved);
|
||||
|
||||
Instant resolvedTime = Instant.now().minusSeconds(10);
|
||||
repo.resolve(resolved.id(), resolvedTime);
|
||||
|
||||
repo.deleteResolvedBefore(Instant.now());
|
||||
|
||||
assertThat(repo.findById(firing.id())).isPresent();
|
||||
assertThat(repo.findById(resolved.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markSilenced_togglesToTrue() {
|
||||
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
|
||||
repo.save(inst);
|
||||
|
||||
assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isFalse();
|
||||
repo.markSilenced(inst.id(), true);
|
||||
assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertInstance newInstance(UUID ruleId,
|
||||
List<String> userIds,
|
||||
List<UUID> groupIds,
|
||||
List<String> roleNames) {
|
||||
return new AlertInstance(
|
||||
UUID.randomUUID(), ruleId, Map.of(), envId,
|
||||
AlertState.FIRING, AlertSeverity.WARNING,
|
||||
Instant.now(), null, null, null, null,
|
||||
false, null, null,
|
||||
Map.of(), "title", "message",
|
||||
userIds, groupIds, roleNames);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user