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:
hsiegeln
2026-04-19 19:05:01 +02:00
parent 45028de1db
commit f829929b07
6 changed files with 693 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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