diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java index 7d7e696c..22308e77 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java @@ -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 evalState) { + return ruleWith(condition, evalState, null); + } + + private AlertRule ruleWith(AlertCondition condition, Map 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 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);