feat(alerting): Postgres repositories for silences, notifications, reads
PostgresAlertSilenceRepository: save/findById roundtrip, listActive (BETWEEN starts_at AND ends_at), listByEnvironment, delete. JSONB SilenceMatcher via ObjectMapper. PostgresAlertNotificationRepository: save/findById, listForInstance, claimDueNotifications (UPDATE...RETURNING with FOR UPDATE SKIP LOCKED), markDelivered, scheduleRetry (bumps attempts + next_attempt_at), markFailed, deleteSettledBefore (DELIVERED+FAILED rows older than cutoff). JSONB payload. PostgresAlertReadRepository: markRead (ON CONFLICT DO NOTHING idempotent), bulkMarkRead (iterates, handles empty list without error). 16 IT scenarios across 3 classes, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
package com.cameleer.server.app.alerting.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.alerting.AlertNotification;
|
||||||
|
import com.cameleer.server.core.alerting.AlertNotificationRepository;
|
||||||
|
import com.cameleer.server.core.alerting.NotificationStatus;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class PostgresAlertNotificationRepository implements AlertNotificationRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper om;
|
||||||
|
|
||||||
|
public PostgresAlertNotificationRepository(JdbcTemplate jdbc, ObjectMapper om) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.om = om;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AlertNotification save(AlertNotification n) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO alert_notifications (
|
||||||
|
id, alert_instance_id, webhook_id, outbound_connection_id,
|
||||||
|
status, attempts, next_attempt_at, claimed_by, claimed_until,
|
||||||
|
last_response_status, last_response_snippet, payload, delivered_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?,
|
||||||
|
?::notification_status_enum, ?, ?, ?, ?,
|
||||||
|
?, ?, ?::jsonb, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
attempts = EXCLUDED.attempts,
|
||||||
|
next_attempt_at = EXCLUDED.next_attempt_at,
|
||||||
|
claimed_by = EXCLUDED.claimed_by,
|
||||||
|
claimed_until = EXCLUDED.claimed_until,
|
||||||
|
last_response_status = EXCLUDED.last_response_status,
|
||||||
|
last_response_snippet = EXCLUDED.last_response_snippet,
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
delivered_at = EXCLUDED.delivered_at
|
||||||
|
""",
|
||||||
|
n.id(), n.alertInstanceId(), n.webhookId(), n.outboundConnectionId(),
|
||||||
|
n.status().name(), n.attempts(), Timestamp.from(n.nextAttemptAt()),
|
||||||
|
n.claimedBy(), n.claimedUntil() == null ? null : Timestamp.from(n.claimedUntil()),
|
||||||
|
n.lastResponseStatus(), n.lastResponseSnippet(),
|
||||||
|
writeJson(n.payload()),
|
||||||
|
n.deliveredAt() == null ? null : Timestamp.from(n.deliveredAt()),
|
||||||
|
Timestamp.from(n.createdAt()));
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AlertNotification> findById(UUID id) {
|
||||||
|
var list = jdbc.query("SELECT * FROM alert_notifications WHERE id = ?", rowMapper(), id);
|
||||||
|
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AlertNotification> listForInstance(UUID alertInstanceId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT * FROM alert_notifications
|
||||||
|
WHERE alert_instance_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""", rowMapper(), alertInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AlertNotification> claimDueNotifications(String instanceId, int batchSize, int claimTtlSeconds) {
|
||||||
|
String sql = """
|
||||||
|
UPDATE alert_notifications
|
||||||
|
SET claimed_by = ?, claimed_until = now() + (? || ' seconds')::interval
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM alert_notifications
|
||||||
|
WHERE status = 'PENDING'::notification_status_enum
|
||||||
|
AND next_attempt_at <= now()
|
||||||
|
AND (claimed_until IS NULL OR claimed_until < now())
|
||||||
|
ORDER BY next_attempt_at
|
||||||
|
LIMIT ?
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
""";
|
||||||
|
return jdbc.query(sql, rowMapper(), instanceId, claimTtlSeconds, batchSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markDelivered(UUID id, int status, String snippet, Instant when) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE alert_notifications
|
||||||
|
SET status = 'DELIVERED'::notification_status_enum,
|
||||||
|
last_response_status = ?,
|
||||||
|
last_response_snippet = ?,
|
||||||
|
delivered_at = ?,
|
||||||
|
claimed_by = NULL,
|
||||||
|
claimed_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", status, snippet, Timestamp.from(when), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void scheduleRetry(UUID id, Instant nextAttemptAt, int status, String snippet) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE alert_notifications
|
||||||
|
SET attempts = attempts + 1,
|
||||||
|
next_attempt_at = ?,
|
||||||
|
last_response_status = ?,
|
||||||
|
last_response_snippet = ?,
|
||||||
|
claimed_by = NULL,
|
||||||
|
claimed_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", Timestamp.from(nextAttemptAt), status, snippet, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markFailed(UUID id, int status, String snippet) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE alert_notifications
|
||||||
|
SET status = 'FAILED'::notification_status_enum,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
last_response_status = ?,
|
||||||
|
last_response_snippet = ?,
|
||||||
|
claimed_by = NULL,
|
||||||
|
claimed_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", status, snippet, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteSettledBefore(Instant cutoff) {
|
||||||
|
jdbc.update("""
|
||||||
|
DELETE FROM alert_notifications
|
||||||
|
WHERE status IN ('DELIVERED'::notification_status_enum, 'FAILED'::notification_status_enum)
|
||||||
|
AND created_at < ?
|
||||||
|
""", Timestamp.from(cutoff));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private RowMapper<AlertNotification> rowMapper() {
|
||||||
|
return (rs, i) -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = om.readValue(
|
||||||
|
rs.getString("payload"), new TypeReference<>() {});
|
||||||
|
Timestamp claimedUntil = rs.getTimestamp("claimed_until");
|
||||||
|
Timestamp deliveredAt = rs.getTimestamp("delivered_at");
|
||||||
|
Object lastStatus = rs.getObject("last_response_status");
|
||||||
|
|
||||||
|
Object webhookIdObj = rs.getObject("webhook_id");
|
||||||
|
UUID webhookId = webhookIdObj == null ? null : (UUID) webhookIdObj;
|
||||||
|
Object connIdObj = rs.getObject("outbound_connection_id");
|
||||||
|
UUID connId = connIdObj == null ? null : (UUID) connIdObj;
|
||||||
|
|
||||||
|
return new AlertNotification(
|
||||||
|
(UUID) rs.getObject("id"),
|
||||||
|
(UUID) rs.getObject("alert_instance_id"),
|
||||||
|
webhookId,
|
||||||
|
connId,
|
||||||
|
NotificationStatus.valueOf(rs.getString("status")),
|
||||||
|
rs.getInt("attempts"),
|
||||||
|
rs.getTimestamp("next_attempt_at").toInstant(),
|
||||||
|
rs.getString("claimed_by"),
|
||||||
|
claimedUntil == null ? null : claimedUntil.toInstant(),
|
||||||
|
lastStatus == null ? null : ((Number) lastStatus).intValue(),
|
||||||
|
rs.getString("last_response_snippet"),
|
||||||
|
payload,
|
||||||
|
deliveredAt == null ? null : deliveredAt.toInstant(),
|
||||||
|
rs.getTimestamp("created_at").toInstant());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to map alert_notifications row", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object o) {
|
||||||
|
try { return om.writeValueAsString(o); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException("Failed to serialize JSON", e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.cameleer.server.app.alerting.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.alerting.AlertReadRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class PostgresAlertReadRepository implements AlertReadRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public PostgresAlertReadRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markRead(String userId, UUID alertInstanceId) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO alert_reads (user_id, alert_instance_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT (user_id, alert_instance_id) DO NOTHING
|
||||||
|
""", userId, alertInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void bulkMarkRead(String userId, List<UUID> alertInstanceIds) {
|
||||||
|
if (alertInstanceIds == null || alertInstanceIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (UUID id : alertInstanceIds) {
|
||||||
|
markRead(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.cameleer.server.app.alerting.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.alerting.AlertSilence;
|
||||||
|
import com.cameleer.server.core.alerting.AlertSilenceRepository;
|
||||||
|
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||||
|
import com.cameleer.server.core.alerting.SilenceMatcher;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class PostgresAlertSilenceRepository implements AlertSilenceRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper om;
|
||||||
|
|
||||||
|
public PostgresAlertSilenceRepository(JdbcTemplate jdbc, ObjectMapper om) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.om = om;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AlertSilence save(AlertSilence s) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO alert_silences (id, environment_id, matcher, reason, starts_at, ends_at, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?::jsonb, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
matcher = EXCLUDED.matcher,
|
||||||
|
reason = EXCLUDED.reason,
|
||||||
|
starts_at = EXCLUDED.starts_at,
|
||||||
|
ends_at = EXCLUDED.ends_at
|
||||||
|
""",
|
||||||
|
s.id(), s.environmentId(), writeJson(s.matcher()),
|
||||||
|
s.reason(),
|
||||||
|
Timestamp.from(s.startsAt()), Timestamp.from(s.endsAt()),
|
||||||
|
s.createdBy(), Timestamp.from(s.createdAt()));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AlertSilence> findById(UUID id) {
|
||||||
|
var list = jdbc.query("SELECT * FROM alert_silences WHERE id = ?", rowMapper(), id);
|
||||||
|
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AlertSilence> listActive(UUID environmentId, Instant when) {
|
||||||
|
Timestamp t = Timestamp.from(when);
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT * FROM alert_silences
|
||||||
|
WHERE environment_id = ?
|
||||||
|
AND starts_at <= ? AND ends_at >= ?
|
||||||
|
ORDER BY starts_at
|
||||||
|
""", rowMapper(), environmentId, t, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AlertSilence> listByEnvironment(UUID environmentId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT * FROM alert_silences
|
||||||
|
WHERE environment_id = ?
|
||||||
|
ORDER BY starts_at DESC
|
||||||
|
""", rowMapper(), environmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(UUID id) {
|
||||||
|
jdbc.update("DELETE FROM alert_silences WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private RowMapper<AlertSilence> rowMapper() {
|
||||||
|
return (rs, i) -> {
|
||||||
|
try {
|
||||||
|
SilenceMatcher matcher = om.readValue(rs.getString("matcher"), SilenceMatcher.class);
|
||||||
|
return new AlertSilence(
|
||||||
|
(UUID) rs.getObject("id"),
|
||||||
|
(UUID) rs.getObject("environment_id"),
|
||||||
|
matcher,
|
||||||
|
rs.getString("reason"),
|
||||||
|
rs.getTimestamp("starts_at").toInstant(),
|
||||||
|
rs.getTimestamp("ends_at").toInstant(),
|
||||||
|
rs.getString("created_by"),
|
||||||
|
rs.getTimestamp("created_at").toInstant());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to map alert_silences row", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object o) {
|
||||||
|
try { return om.writeValueAsString(o); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException("Failed to serialize JSON", e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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 PostgresAlertNotificationRepositoryIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
private PostgresAlertNotificationRepository repo;
|
||||||
|
private UUID envId;
|
||||||
|
private UUID instanceId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
repo = new PostgresAlertNotificationRepository(jdbcTemplate, new ObjectMapper());
|
||||||
|
envId = UUID.randomUUID();
|
||||||
|
instanceId = UUID.randomUUID();
|
||||||
|
UUID 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 ('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);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||||
|
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||||
|
"now(), '{}'::jsonb, 'title', 'msg')",
|
||||||
|
instanceId, ruleId, envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id = ?", instanceId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveAndFindByIdRoundtrip() {
|
||||||
|
var n = newNotification();
|
||||||
|
repo.save(n);
|
||||||
|
|
||||||
|
var found = repo.findById(n.id()).orElseThrow();
|
||||||
|
assertThat(found.id()).isEqualTo(n.id());
|
||||||
|
assertThat(found.status()).isEqualTo(NotificationStatus.PENDING);
|
||||||
|
assertThat(found.alertInstanceId()).isEqualTo(instanceId);
|
||||||
|
assertThat(found.payload()).containsKey("key");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claimDueNotifications_claimsAndSkipsLocked() {
|
||||||
|
var n = newNotification();
|
||||||
|
repo.save(n);
|
||||||
|
|
||||||
|
var claimed = repo.claimDueNotifications("worker-1", 10, 30);
|
||||||
|
assertThat(claimed).hasSize(1);
|
||||||
|
assertThat(claimed.get(0).claimedBy()).isEqualTo("worker-1");
|
||||||
|
|
||||||
|
// second claimant sees nothing
|
||||||
|
var second = repo.claimDueNotifications("worker-2", 10, 30);
|
||||||
|
assertThat(second).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markDelivered_setsStatusAndDeliveredAt() {
|
||||||
|
var n = newNotification();
|
||||||
|
repo.save(n);
|
||||||
|
|
||||||
|
repo.markDelivered(n.id(), 200, "OK", Instant.now());
|
||||||
|
|
||||||
|
var found = repo.findById(n.id()).orElseThrow();
|
||||||
|
assertThat(found.status()).isEqualTo(NotificationStatus.DELIVERED);
|
||||||
|
assertThat(found.lastResponseStatus()).isEqualTo(200);
|
||||||
|
assertThat(found.deliveredAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scheduleRetry_bumpsAttemptsAndNextAttempt() {
|
||||||
|
var n = newNotification();
|
||||||
|
repo.save(n);
|
||||||
|
|
||||||
|
Instant nextAttempt = Instant.now().plusSeconds(60);
|
||||||
|
repo.scheduleRetry(n.id(), nextAttempt, 503, "Service Unavailable");
|
||||||
|
|
||||||
|
var found = repo.findById(n.id()).orElseThrow();
|
||||||
|
assertThat(found.attempts()).isEqualTo(1);
|
||||||
|
assertThat(found.status()).isEqualTo(NotificationStatus.PENDING); // still pending
|
||||||
|
assertThat(found.lastResponseStatus()).isEqualTo(503);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markFailed_setsStatusFailed() {
|
||||||
|
var n = newNotification();
|
||||||
|
repo.save(n);
|
||||||
|
|
||||||
|
repo.markFailed(n.id(), 400, "Bad Request");
|
||||||
|
|
||||||
|
var found = repo.findById(n.id()).orElseThrow();
|
||||||
|
assertThat(found.status()).isEqualTo(NotificationStatus.FAILED);
|
||||||
|
assertThat(found.lastResponseStatus()).isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSettledBefore_deletesDeliveredAndFailed() {
|
||||||
|
var pending = newNotification();
|
||||||
|
var delivered = newNotification();
|
||||||
|
var failed = newNotification();
|
||||||
|
|
||||||
|
repo.save(pending);
|
||||||
|
repo.save(delivered);
|
||||||
|
repo.save(failed);
|
||||||
|
|
||||||
|
repo.markDelivered(delivered.id(), 200, "OK", Instant.now().minusSeconds(3600));
|
||||||
|
repo.markFailed(failed.id(), 500, "Error");
|
||||||
|
|
||||||
|
// deleteSettledBefore uses created_at — use future cutoff to delete all settled
|
||||||
|
repo.deleteSettledBefore(Instant.now().plusSeconds(60));
|
||||||
|
|
||||||
|
assertThat(repo.findById(pending.id())).isPresent();
|
||||||
|
assertThat(repo.findById(delivered.id())).isEmpty();
|
||||||
|
assertThat(repo.findById(failed.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listForInstance_returnsAll() {
|
||||||
|
repo.save(newNotification());
|
||||||
|
repo.save(newNotification());
|
||||||
|
|
||||||
|
var list = repo.listForInstance(instanceId);
|
||||||
|
assertThat(list).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private AlertNotification newNotification() {
|
||||||
|
return new AlertNotification(
|
||||||
|
UUID.randomUUID(), instanceId,
|
||||||
|
UUID.randomUUID(), null,
|
||||||
|
NotificationStatus.PENDING, 0,
|
||||||
|
Instant.now().minusSeconds(10),
|
||||||
|
null, null,
|
||||||
|
null, null,
|
||||||
|
Map.of("key", "value"),
|
||||||
|
null, Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.cameleer.server.app.alerting.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.AbstractPostgresIT;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
class PostgresAlertReadRepositoryIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
private PostgresAlertReadRepository repo;
|
||||||
|
private UUID envId;
|
||||||
|
private UUID instanceId1;
|
||||||
|
private UUID instanceId2;
|
||||||
|
private UUID instanceId3;
|
||||||
|
private final String userId = "read-user-" + UUID.randomUUID();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
repo = new PostgresAlertReadRepository(jdbcTemplate);
|
||||||
|
envId = UUID.randomUUID();
|
||||||
|
instanceId1 = UUID.randomUUID();
|
||||||
|
instanceId2 = UUID.randomUUID();
|
||||||
|
instanceId3 = UUID.randomUUID();
|
||||||
|
UUID 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 ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
for (UUID id : List.of(instanceId1, instanceId2, instanceId3)) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
|
||||||
|
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
|
||||||
|
"now(), '{}'::jsonb, 'title', 'msg')",
|
||||||
|
id, ruleId, envId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
|
||||||
|
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 markRead_insertsReadRecord() {
|
||||||
|
repo.markRead(userId, instanceId1);
|
||||||
|
|
||||||
|
int count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||||
|
Integer.class, userId, instanceId1);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markRead_isIdempotent() {
|
||||||
|
repo.markRead(userId, instanceId1);
|
||||||
|
// second call should not throw
|
||||||
|
assertThatCode(() -> repo.markRead(userId, instanceId1)).doesNotThrowAnyException();
|
||||||
|
|
||||||
|
int count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
|
||||||
|
Integer.class, userId, instanceId1);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bulkMarkRead_marksMultiple() {
|
||||||
|
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2, instanceId3));
|
||||||
|
|
||||||
|
int count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||||
|
Integer.class, userId);
|
||||||
|
assertThat(count).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bulkMarkRead_emptyListDoesNotThrow() {
|
||||||
|
assertThatCode(() -> repo.bulkMarkRead(userId, List.of())).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bulkMarkRead_isIdempotent() {
|
||||||
|
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2));
|
||||||
|
assertThatCode(() -> repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2)))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
|
||||||
|
int count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
|
||||||
|
Integer.class, userId);
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.cameleer.server.app.alerting.storage;
|
||||||
|
|
||||||
|
import com.cameleer.server.app.AbstractPostgresIT;
|
||||||
|
import com.cameleer.server.core.alerting.AlertSilence;
|
||||||
|
import com.cameleer.server.core.alerting.SilenceMatcher;
|
||||||
|
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.time.temporal.ChronoUnit;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PostgresAlertSilenceRepositoryIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
private PostgresAlertSilenceRepository repo;
|
||||||
|
private UUID envId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
repo = new PostgresAlertSilenceRepository(jdbcTemplate, new ObjectMapper());
|
||||||
|
envId = 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 ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
|
||||||
|
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveAndFindByIdRoundtrip() {
|
||||||
|
var silence = newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600));
|
||||||
|
repo.save(silence);
|
||||||
|
|
||||||
|
var found = repo.findById(silence.id()).orElseThrow();
|
||||||
|
assertThat(found.id()).isEqualTo(silence.id());
|
||||||
|
assertThat(found.environmentId()).isEqualTo(envId);
|
||||||
|
assertThat(found.reason()).isEqualTo("test reason");
|
||||||
|
assertThat(found.matcher()).isNotNull();
|
||||||
|
assertThat(found.matcher().isWildcard()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listActive_returnsOnlyCurrentSilences() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
var active = newSilence(now.minusSeconds(60), now.plusSeconds(3600));
|
||||||
|
var future = newSilence(now.plusSeconds(60), now.plusSeconds(7200));
|
||||||
|
var past = newSilence(now.minusSeconds(7200), now.minusSeconds(60));
|
||||||
|
|
||||||
|
repo.save(active);
|
||||||
|
repo.save(future);
|
||||||
|
repo.save(past);
|
||||||
|
|
||||||
|
var result = repo.listActive(envId, now);
|
||||||
|
assertThat(result).extracting(AlertSilence::id)
|
||||||
|
.containsExactly(active.id())
|
||||||
|
.doesNotContain(future.id(), past.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_removesRow() {
|
||||||
|
var silence = newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600));
|
||||||
|
repo.save(silence);
|
||||||
|
assertThat(repo.findById(silence.id())).isPresent();
|
||||||
|
|
||||||
|
repo.delete(silence.id());
|
||||||
|
|
||||||
|
assertThat(repo.findById(silence.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listByEnvironment_returnsAll() {
|
||||||
|
repo.save(newSilence(Instant.now().minusSeconds(60), Instant.now().plusSeconds(3600)));
|
||||||
|
repo.save(newSilence(Instant.now().minusSeconds(30), Instant.now().plusSeconds(1800)));
|
||||||
|
|
||||||
|
var list = repo.listByEnvironment(envId);
|
||||||
|
assertThat(list).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private AlertSilence newSilence(Instant startsAt, Instant endsAt) {
|
||||||
|
return new AlertSilence(
|
||||||
|
UUID.randomUUID(), envId,
|
||||||
|
new SilenceMatcher(null, null, null, null, null),
|
||||||
|
"test reason", startsAt, endsAt, "sys-user", Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user