diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java index 8757bda9..76392912 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java @@ -246,6 +246,61 @@ class AlertRuleControllerIT extends AbstractPostgresIT { assertThat(preview.getStatusCode()).isEqualTo(HttpStatus.OK); } + // --- PER_EXCHANGE cross-field validation + empty-targets validation --- + // RED tests: today's controller accepts these bodies; Task 3.3 adds the validator. + + @Test + void createPerExchangeRule_withReNotifyMinutesNonZero_returns400() { + String body = perExchangeRuleBodyWithExtras( + "per-exchange-renotify", + /*reNotifyMinutes*/ 60, + /*forDurationSeconds*/ null); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).contains("reNotifyMinutes"); + } + + @Test + void createPerExchangeRule_withForDurationSecondsNonZero_returns400() { + String body = perExchangeRuleBodyWithExtras( + "per-exchange-forduration", + /*reNotifyMinutes*/ null, + /*forDurationSeconds*/ 60); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).contains("forDurationSeconds"); + } + + @Test + void createAnyRule_withEmptyWebhooksAndTargets_returns400() { + // baseValidPerExchangeRuleRequest() already produces no webhooks / no targets — that's + // precisely the "empty webhooks + empty targets" shape this test pins as a 400. + String body = baseValidPerExchangeRuleRequest("no-sinks"); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody()).satisfiesAnyOf( + s -> assertThat(s).contains("webhook"), + s -> assertThat(s).contains("target")); + } + // --- Unknown env returns 404 --- @Test @@ -275,4 +330,41 @@ class AlertRuleControllerIT extends AbstractPostgresIT { "metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60}} """.formatted(name); } + + /** + * Produces a request body for a valid PER_EXCHANGE rule (baseline) — no webhooks, + * no targets, no reNotifyMinutes, no forDurationSeconds. The controller currently + * accepts this shape; Task 3.3 tightens that (empty sinks will 400). + */ + private static String baseValidPerExchangeRuleRequest(String name) { + return """ + {"name":"%s","severity":"WARNING","conditionKind":"EXCHANGE_MATCH", + "condition":{"kind":"EXCHANGE_MATCH","scope":{}, + "filter":{"status":"FAILED","attributes":{}}, + "fireMode":"PER_EXCHANGE"}} + """.formatted(name); + } + + /** + * Variant of {@link #baseValidPerExchangeRuleRequest(String)} that sets + * reNotifyMinutes and/or forDurationSeconds at the top-level request. Used to pin + * the PER_EXCHANGE cross-field validation contract (Task 3.3). + */ + private static String perExchangeRuleBodyWithExtras(String name, + Integer reNotifyMinutes, + Integer forDurationSeconds) { + StringBuilder extras = new StringBuilder(); + if (reNotifyMinutes != null) { + extras.append(",\"reNotifyMinutes\":").append(reNotifyMinutes); + } + if (forDurationSeconds != null) { + extras.append(",\"forDurationSeconds\":").append(forDurationSeconds); + } + return """ + {"name":"%s","severity":"WARNING","conditionKind":"EXCHANGE_MATCH", + "condition":{"kind":"EXCHANGE_MATCH","scope":{}, + "filter":{"status":"FAILED","attributes":{}}, + "fireMode":"PER_EXCHANGE"}%s} + """.formatted(name, extras.toString()); + } }