diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java index 77e73b5e..6043cca6 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java @@ -7,8 +7,10 @@ import com.cameleer.server.app.alerting.eval.AlertEvaluatorJob; import com.cameleer.server.app.alerting.notify.NotificationDispatchJob; import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.storage.ClickHouseExecutionStore; import com.cameleer.server.core.alerting.*; import com.cameleer.server.core.ingestion.BufferedLogEntry; +import com.cameleer.server.core.ingestion.MergedExecution; import com.cameleer.server.core.outbound.OutboundConnectionRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,6 +64,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT { @Autowired private AlertSilenceRepository silenceRepo; @Autowired private OutboundConnectionRepository outboundRepo; @Autowired private ClickHouseLogStore logStore; + @Autowired private ClickHouseExecutionStore executionStore; @Autowired private SecretCipher secretCipher; @Autowired private TestRestTemplate restTemplate; @Autowired private TestSecurityHelper securityHelper; @@ -399,6 +402,102 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT { jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", reNotifyRuleId); } + /** + * Exactly-once-per-exchange end-to-end lifecycle. + *
+ * 5 FAILED exchanges across 2 evaluator ticks must produce exactly + * 5 FIRING instances + 5 PENDING notifications (one per exchange, one webhook). + * A third tick with no new exchanges must be a no-op. Acking one instance + * must leave the other four untouched. + *
+ * Exercises the full Phase-1+2+3 stack: evaluator cursor persistence across
+ * ticks, per-tick rollback isolation, and the ack-doesn't-cascade invariant.
+ * See: docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md
+ */
+ @Test
+ @Order(7)
+ void perExchange_5FailuresAcross2Ticks_exactlyOncePerExchange() {
+ // Relative-to-now timestamps so they fall inside the evaluator's
+ // [rule.createdAt .. ctx.now()] window. Using Instant.parse(...) would
+ // require reconciling with the mocked alertingClock AND rule.createdAt,
+ // which is wall-clock in createPerExchangeRuleWithWebhook.
+ Instant base = Instant.now().minusSeconds(30);
+
+ // Pin the mocked alertingClock to current wall time so ctx.now() is >
+ // every seeded execution timestamp (base + 0..4s) AND > rule.createdAt
+ // (now - 60s). Prior tests may have set simulatedNow far in the past
+ // (step1 used wall time but step6 advanced by 61s — test ordering means
+ // the last value lingers). Re-pinning here makes the window deterministic.
+ setSimulatedNow(Instant.now());
+
+ UUID perExRuleId = createPerExchangeRuleWithWebhook();
+
+ // ── Tick 1 — seed 3, tick ────────────────────────────────────────────
+ seedFailedExecution("ex1-exec-1", base);
+ seedFailedExecution("ex1-exec-2", base.plusSeconds(1));
+ seedFailedExecution("ex1-exec-3", base.plusSeconds(2));
+ evaluatorJob.tick();
+
+ // ── Tick 2 — seed 2 more, tick ───────────────────────────────────────
+ seedFailedExecution("ex1-exec-4", base.plusSeconds(3));
+ seedFailedExecution("ex1-exec-5", base.plusSeconds(4));
+ // Re-open the rule claim so it's due for tick 2.
+ jdbcTemplate.update(
+ "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
+ "claimed_by = NULL, claimed_until = NULL WHERE id = ?", perExRuleId);
+ evaluatorJob.tick();
+
+ // Assert: 5 instances, 5 PENDING notifications.
+ List
+ * Replicates the pattern from {@code AlertEvaluatorJobIT#createPerExchangeRuleWithWebhook}
+ * but reuses this test's env + outbound connection.
+ */
+ private UUID createPerExchangeRuleWithWebhook() {
+ UUID rid = UUID.randomUUID();
+ Instant now = Instant.now();
+ var condition = new ExchangeMatchCondition(
+ new AlertScope(PER_EX_APP_SLUG, null, null),
+ new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
+ FireMode.PER_EXCHANGE, null, null);
+ var webhook = new WebhookBinding(connId, null, null, Map.of());
+ var rule = new AlertRule(
+ rid, envId, "per-ex-lc-rule-" + rid, null,
+ AlertSeverity.WARNING, true, ConditionKind.EXCHANGE_MATCH, condition,
+ 60, 0, 60,
+ "Exchange FAILED: {{exchange.id}}", "route={{exchange.routeId}}",
+ List.of(webhook), List.of(),
+ now.minusSeconds(5), // due now
+ null, null, Map.of(),
+ now.minusSeconds(60), "test-operator", // createdAt bounds first-run cursor
+ now.minusSeconds(60), "test-operator");
+ ruleRepo.save(rule);
+ return rid;
+ }
+
+ /**
+ * Seed one FAILED execution into ClickHouse, scoped to this test's tenant/env/app
+ * so it's picked up by a PER_EXCHANGE rule targeting {@link #PER_EX_APP_SLUG}.
+ */
+ private void seedFailedExecution(String executionId, Instant startTime) {
+ executionStore.insertExecutionBatch(List.of(new MergedExecution(
+ tenantId, 1L, executionId, "route-a", "inst-1", PER_EX_APP_SLUG, envSlug,
+ "FAILED", "", "exchange-" + executionId,
+ startTime, startTime.plusMillis(100), 100L,
+ "", "", "", "", "", "", // error fields
+ "", "FULL", // diagramContentHash, engineLevel
+ "", "", "", "", "", "", // bodies / headers / properties
+ "{}", // attributes (JSON)
+ "", "", // traceId, spanId
+ false, false,
+ null, null
+ )));
+ }
+
+ /** All instance ids for a rule, ordered by fired_at ascending (deterministic). */
+ private List