alerting(eval): RED tests for PER_EXCHANGE cursor monotonicity + first-run bound

Two failing tests documenting the contract Task 1.5 will satisfy:
- cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick
- firstRun_boundedByRuleCreatedAt_notRetentionHistory

Compile may fail until Task 1.4 adds AlertRule.withEvalState wither.
This commit is contained in:
hsiegeln
2026-04-22 15:58:16 +02:00
parent b41f34c090
commit c2252a0e72

View File

@@ -5,11 +5,13 @@ import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository; import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.search.ExecutionSummary; import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult; import com.cameleer.server.core.search.SearchResult;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -45,10 +47,21 @@ class ExchangeMatchEvaluatorTest {
} }
private AlertRule ruleWith(AlertCondition condition, Map<String, Object> evalState) { private AlertRule ruleWith(AlertCondition condition, Map<String, Object> evalState) {
return ruleWith(condition, evalState, null);
}
private AlertRule ruleWith(AlertCondition condition, Map<String, Object> evalState, Instant createdAt) {
return new AlertRule(RULE_ID, ENV_ID, "test", null, return new AlertRule(RULE_ID, ENV_ID, "test", null,
AlertSeverity.WARNING, true, condition.kind(), condition, AlertSeverity.WARNING, true, condition.kind(), condition,
60, 0, 0, null, null, List.of(), List.of(), 60, 0, 0, null, null, List.of(), List.of(),
null, null, null, evalState, null, null, null, null); null, null, null, evalState, createdAt, null, null, null);
}
private ExchangeMatchCondition perExchangeCondition() {
return new ExchangeMatchCondition(
new AlertScope("orders", null, null),
new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
FireMode.PER_EXCHANGE, null, null, 60);
} }
private ExecutionSummary summary(String id, Instant startTime, String status) { private ExecutionSummary summary(String id, Instant startTime, String status) {
@@ -197,6 +210,59 @@ class ExchangeMatchEvaluatorTest {
assertThat(captor.getValue().timeFrom()).isEqualTo(cursor); assertThat(captor.getValue().timeFrom()).isEqualTo(cursor);
} }
@Test
void cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick() {
var t = Instant.parse("2026-04-22T10:00:00Z");
var exA = summary("exec-a", t, "FAILED");
var exB = summary("exec-b", t, "FAILED");
when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(exA, exB), 2L, 0, 50));
ExchangeMatchCondition condition = perExchangeCondition();
AlertRule rule = ruleWith(condition, Map.of()).withEvalState(Map.of()); // first-run
EvalResult r1 = eval.evaluate(condition, rule,
new EvalContext("default", t.plusSeconds(1), new TickCache()));
assertThat(r1).isInstanceOf(EvalResult.Batch.class);
var batch1 = (EvalResult.Batch) r1;
assertThat(batch1.firings()).hasSize(2);
assertThat(batch1.nextEvalState()).containsKey("lastExchangeCursor");
// cursor is (t, "exec-b") since "exec-b" > "exec-a" lexicographically
// Tick 2: reflect the advanced cursor back; expect zero firings
AlertRule advanced = rule.withEvalState(batch1.nextEvalState());
when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50));
EvalResult r2 = eval.evaluate(condition, advanced,
new EvalContext("default", t.plusSeconds(2), new TickCache()));
assertThat(((EvalResult.Batch) r2).firings()).isEmpty();
// Tick 3: a third exchange at the same t with exec-c > exec-b; expect exactly one firing
var exC = summary("exec-c", t, "FAILED");
when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(exC), 1L, 0, 50));
EvalResult r3 = eval.evaluate(condition, advanced,
new EvalContext("default", t.plusSeconds(3), new TickCache()));
assertThat(((EvalResult.Batch) r3).firings()).hasSize(1);
}
@Test
void firstRun_boundedByRuleCreatedAt_notRetentionHistory() {
var created = Instant.parse("2026-04-22T09:00:00Z");
var after = created.plus(Duration.ofMinutes(30));
// The evaluator must pass `timeFrom = created` to the search.
ArgumentCaptor<SearchRequest> cap = ArgumentCaptor.forClass(SearchRequest.class);
when(searchIndex.search(cap.capture())).thenReturn(
new SearchResult<>(List.of(summary("exec-after", after, "FAILED")), 1L, 0, 50));
ExchangeMatchCondition condition = perExchangeCondition();
AlertRule rule = ruleWith(condition, Map.of(), created).withEvalState(Map.of()); // no cursor
EvalResult r = eval.evaluate(condition, rule,
new EvalContext("default", after.plusSeconds(10), new TickCache()));
SearchRequest req = cap.getValue();
assertThat(req.timeFrom()).isEqualTo(created);
assertThat(((EvalResult.Batch) r).firings()).hasSize(1);
}
@Test @Test
void kindIsExchangeMatch() { void kindIsExchangeMatch() {
assertThat(eval.kind()).isEqualTo(ConditionKind.EXCHANGE_MATCH); assertThat(eval.kind()).isEqualTo(ConditionKind.EXCHANGE_MATCH);