diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepository.java new file mode 100644 index 00000000..88bd5e1a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepository.java @@ -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 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 listForInstance(UUID alertInstanceId) { + return jdbc.query(""" + SELECT * FROM alert_notifications + WHERE alert_instance_id = ? + ORDER BY created_at DESC + """, rowMapper(), alertInstanceId); + } + + @Override + public List 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 rowMapper() { + return (rs, i) -> { + try { + Map 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); } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java new file mode 100644 index 00000000..fa6daab4 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java @@ -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 alertInstanceIds) { + if (alertInstanceIds == null || alertInstanceIds.isEmpty()) { + return; + } + for (UUID id : alertInstanceIds) { + markRead(userId, id); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepository.java new file mode 100644 index 00000000..79068d1a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepository.java @@ -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 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 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 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 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); } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java new file mode 100644 index 00000000..b28ade89 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java @@ -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()); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java new file mode 100644 index 00000000..6cd829eb --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java @@ -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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java new file mode 100644 index 00000000..1af01376 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java @@ -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()); + } +}