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:
@@ -5,11 +5,13 @@ import com.cameleer.server.core.alerting.*;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import com.cameleer.server.core.search.ExecutionSummary;
|
||||
import com.cameleer.server.core.search.SearchRequest;
|
||||
import com.cameleer.server.core.search.SearchResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -45,10 +47,21 @@ class ExchangeMatchEvaluatorTest {
|
||||
}
|
||||
|
||||
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,
|
||||
AlertSeverity.WARNING, true, condition.kind(), condition,
|
||||
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) {
|
||||
@@ -197,6 +210,59 @@ class ExchangeMatchEvaluatorTest {
|
||||
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
|
||||
void kindIsExchangeMatch() {
|
||||
assertThat(eval.kind()).isEqualTo(ConditionKind.EXCHANGE_MATCH);
|
||||
|
||||
Reference in New Issue
Block a user