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.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);
|
||||||
|
|||||||
Reference in New Issue
Block a user