DatabaseEvaluateThresholdsTest.java

package redis.clients.jedis.mcf;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.MultiDbConfig;
import redis.clients.jedis.mcf.MultiDbConnectionProvider.Database;

/**
 * Tests for circuit breaker thresholds: both failure-rate threshold and minimum number of failures
 * must be exceeded to trigger failover. Uses a real CircuitBreaker and real Retry, but mocks the
 * provider and {@link Database} wiring to avoid network I/O.
 */
public class DatabaseEvaluateThresholdsTest {

  private MultiDbConnectionProvider provider;
  private Database database;
  private CircuitBreaker circuitBreaker;
  private CircuitBreaker.Metrics metrics;

  @BeforeEach
  public void setup() {
    provider = mock(MultiDbConnectionProvider.class);
    database = mock(Database.class);

    circuitBreaker = mock(CircuitBreaker.class);
    metrics = mock(CircuitBreaker.Metrics.class);

    when(database.getCircuitBreaker()).thenReturn(circuitBreaker);
    when(circuitBreaker.getMetrics()).thenReturn(metrics);
    when(circuitBreaker.getState()).thenReturn(CircuitBreaker.State.CLOSED);

    // Configure the mock to call the real evaluateThresholds method
    doCallRealMethod().when(database).evaluateThresholds(anyBoolean());

  }

  /**
   * Below minimum failures; even if all calls are failures, failover should NOT trigger. Note: The
   * isThresholdsExceeded method adds +1 to account for the current failing call, so we set
   * failures=1 which becomes 2 with +1, still below minFailures=3.
   */
  @Test
  public void belowMinFailures_doesNotFailover() {
    when(database.getCircuitBreakerMinNumOfFailures()).thenReturn(3);
    when(metrics.getNumberOfFailedCalls()).thenReturn(1); // +1 becomes 2, still < 3
    when(metrics.getNumberOfSuccessfulCalls()).thenReturn(0);
    when(database.getCircuitBreakerFailureRateThreshold()).thenReturn(50.0f);
    when(circuitBreaker.getState()).thenReturn(CircuitBreaker.State.CLOSED);

    database.evaluateThresholds(false);
    verify(circuitBreaker, never()).transitionToOpenState();
    verify(provider, never()).switchToHealthyDatabase(any(), any());
  }

  /**
   * Reaching minFailures and exceeding failure rate threshold should trigger circuit breaker to
   * OPEN state. Note: The isThresholdsExceeded method adds +1 to account for the current failing
   * call, so we set failures=2 which becomes 3 with +1, reaching minFailures=3.
   */
  @Test
  public void minFailuresAndRateExceeded_triggersOpenState() {
    when(database.getCircuitBreakerMinNumOfFailures()).thenReturn(3);
    when(metrics.getNumberOfFailedCalls()).thenReturn(2); // +1 becomes 3, reaching minFailures
    when(metrics.getNumberOfSuccessfulCalls()).thenReturn(0);
    when(database.getCircuitBreakerFailureRateThreshold()).thenReturn(50.0f);
    when(circuitBreaker.getState()).thenReturn(CircuitBreaker.State.CLOSED);

    database.evaluateThresholds(false);
    verify(circuitBreaker, times(1)).transitionToOpenState();
  }

  /**
   * Even after reaching minFailures, if failure rate is below threshold, do not failover. Note: The
   * isThresholdsExceeded method adds +1 to account for the current failing call, so we set
   * failures=2 which becomes 3 with +1, reaching minFailures=3. Rate calculation: (3 failures) / (3
   * failures + 3 successes) = 50% < 80% threshold.
   */
  @Test
  public void rateBelowThreshold_doesNotFailover() {
    when(database.getCircuitBreakerMinNumOfFailures()).thenReturn(3);
    when(metrics.getNumberOfSuccessfulCalls()).thenReturn(3);
    when(metrics.getNumberOfFailedCalls()).thenReturn(2); // +1 becomes 3, rate = 3/(3+3) = 50%
    when(database.getCircuitBreakerFailureRateThreshold()).thenReturn(80.0f);
    when(circuitBreaker.getState()).thenReturn(CircuitBreaker.State.CLOSED);

    database.evaluateThresholds(false);

    verify(circuitBreaker, never()).transitionToOpenState();
    verify(provider, never()).switchToHealthyDatabase(any(), any());
  }

  @Test
  public void providerBuilder_zeroRate_mapsToHundredAndHugeMinCalls() {
    MultiDbConfig.Builder cfgBuilder = MultiDbConfig
        .builder(java.util.Arrays.asList(MultiDbConfig.DatabaseConfig
            .builder(new HostAndPort("localhost", 6379), DefaultJedisClientConfig.builder().build())
            .healthCheckEnabled(false).build()));
    cfgBuilder.failureDetector(MultiDbConfig.CircuitBreakerConfig.builder()
        .failureRateThreshold(0.0f).minNumOfFailures(3).slidingWindowSize(10).build());
    MultiDbConfig mcc = cfgBuilder.build();

    CircuitBreakerThresholdsAdapter adapter = new CircuitBreakerThresholdsAdapter(mcc);

    assertEquals(100.0f, adapter.getFailureRateThreshold(), 0.0001f);
    assertEquals(Integer.MAX_VALUE, adapter.getMinimumNumberOfCalls());
  }

  @ParameterizedTest
  @CsvSource({
      // Format: "minFails, rate%, success, fails, lastFailRecorded, expected"

      // === Basic threshold crossing cases ===
      "0, 1.0, 0, 1, false, true", // +1 = 2 fails, rate=100% >= 1%, min=0 -> trigger
      "0, 1.0, 0, 1, true, true", // +0 = 1 fails, rate=100% >= 1%, min=0 -> trigger

      "1, 1.0, 0, 0, false, true", // +1 = 1 fails, rate=100% >= 1%, min=1 -> trigger
      "1, 1.0, 0, 0, true, false", // +0 = 0 fails, 0 < 1 min -> no trigger

      "3, 50.0, 0, 2, false, true", // +1 = 3 fails, rate=100% >= 50%, min=3 -> trigger
      "3, 50.0, 0, 2, true, false", // +0 = 2 fails, 2 < 3 min -> no trigger

      // === Rate threshold boundary cases ===
      "1, 100.0, 0, 0, false, true", // +1 = 1 fails, rate=100% >= 100%, min=1 -> trigger
      "1, 100.0, 0, 0, true, false", // +0 = 0 fails, 0 < 1 min -> no trigger

      "0, 100.0, 99, 1, false, false", // +1 = 2 fails, rate=1.98% < 100% -> no trigger
      "0, 100.0, 99, 1, true, false", // +0 = 1 fails, rate=1.0% < 100% -> no trigger

      "0, 1.0, 99, 1, false, true", // +1 = 2 fails, rate=1.98% >= 1%, min=0 -> trigger
      "0, 1.0, 99, 1, true, true", // +0 = 1 fails, rate=1.0% >= 1%, min=0 -> trigger

      // === Zero rate threshold (always trigger if min failures met) ===
      "1, 0.0, 0, 0, false, true", // +1 = 1 fails, rate=100% >= 0%, min=1 -> trigger
      "1, 0.0, 0, 0, true, false", // +0 = 0 fails, 0 < 1 min -> no trigger
      "1, 0.0, 100, 0, false, true", // +1 = 1 fails, rate=0.99% >= 0%, min=1 -> trigger
      "1, 0.0, 100, 0, true, false", // +0 = 0 fails, 0 < 1 min -> no trigger

      // === High minimum failures cases ===
      "3, 50.0, 3, 1, false, false", // +1 = 2 fails, 2 < 3 min -> no trigger
      "3, 50.0, 3, 1, true, false", // +0 = 1 fails, 1 < 3 min -> no trigger
      "1000, 1.0, 198, 2, false, false", // +1 = 3 fails, 3 < 1000 min -> no trigger
      "1000, 1.0, 198, 2, true, false", // +0 = 2 fails, 2 < 1000 min -> no trigger

      // === Corner cases ===
      "0, 50.0, 0, 0, false, true", // +1 = 1 fails, rate=100% >= 50%, min=0 -> trigger
      "0, 50.0, 0, 0, true, false", // +0 = 0 fails, no calls -> no trigger
      "1, 50.0, 1, 1, false, true", // +1 = 2 fails, rate=66.7% >= 50%, min=1 -> trigger
      "1, 50.0, 1, 1, true, true", // +0 = 1 fails, rate=50% >= 50%, min=1 -> trigger
      "2, 33.0, 2, 1, false, true", // +1 = 2 fails, rate=50% >= 33%, min=2 -> trigger
      "2, 33.0, 2, 1, true, false", // +0 = 1 fails, 1 < 2 min -> no trigger
      "5, 20.0, 20, 4, false, true", // +1 = 5 fails, rate=20% >= 20%, min=5 -> trigger
      "5, 20.0, 20, 4, true, false", // +0 = 4 fails, 4 < 5 min -> no trigger
      "3, 75.0, 1, 2, false, true", // +1 = 3 fails, rate=75% >= 75%, min=3 -> trigger
      "3, 75.0, 1, 2, true, false", // +0 = 2 fails, 2 < 3 min -> no trigger
  })
  public void thresholdMatrix(int minFailures, float ratePercent, int successes, int failures,
      boolean lastFailRecorded, boolean expectOpenState) {

    when(database.getCircuitBreakerMinNumOfFailures()).thenReturn(minFailures);
    when(metrics.getNumberOfSuccessfulCalls()).thenReturn(successes);
    when(metrics.getNumberOfFailedCalls()).thenReturn(failures);
    when(database.getCircuitBreakerFailureRateThreshold()).thenReturn(ratePercent);
    when(circuitBreaker.getState()).thenReturn(CircuitBreaker.State.CLOSED);

    database.evaluateThresholds(lastFailRecorded);

    if (expectOpenState) {
      verify(circuitBreaker, times(1)).transitionToOpenState();
    } else {
      verify(circuitBreaker, never()).transitionToOpenState();
    }
  }

}