From ed91cf3c72287f2b6197d578755dccf01f38b0ac Mon Sep 17 00:00:00 2001 From: rowing0328 Date: Mon, 22 Jun 2026 14:02:32 +0900 Subject: [PATCH] feat: add experiment run polling helper (#58) Constraint: Default tests must pass without a running LitmusChaos server. Rejected: Running live Litmus tests by default | It makes OSS CI depend on local server state and credentials. Confidence: high Scope-risk: narrow Directive: Keep live Litmus tests opt-in and document required environment variables. Tested: ./mvnw test; ./mvnw test -Dgroups=integration -DexcludedGroups=; ./mvnw package; ./mvnw javadoc:javadoc; ./mvnw -Dtest=ExperimentRunLiveTest -DexcludedGroups= test with local LitmusChaos run f65debdf-1ae2-4c98-a22f-65f5ac808282 Signed-off-by: rowing0328 --- CONTRIBUTING.md | 37 ++++- pom.xml | 6 +- .../java/io/litmuschaos/LitmusClient.java | 40 ++++- .../ExperimentRunTimeoutException.java | 8 + .../litmuschaos/util/ExperimentRunPoller.java | 115 +++++++++++++ src/test/java/io/litmuschaos/AuthTest.java | 7 +- .../java/io/litmuschaos/BuildRequestTest.java | 7 +- .../io/litmuschaos/ExperimentRunLiveTest.java | 51 ++++++ .../io/litmuschaos/IntegrationTestConfig.java | 65 ++++++++ .../java/io/litmuschaos/LitmusClientTest.java | 22 ++- src/test/java/io/litmuschaos/ProjectTest.java | 7 +- src/test/java/io/litmuschaos/UserTest.java | 7 +- .../util/ExperimentRunPollerTest.java | 151 ++++++++++++++++++ 13 files changed, 500 insertions(+), 23 deletions(-) create mode 100644 src/main/java/io/litmuschaos/exception/ExperimentRunTimeoutException.java create mode 100644 src/main/java/io/litmuschaos/util/ExperimentRunPoller.java create mode 100644 src/test/java/io/litmuschaos/ExperimentRunLiveTest.java create mode 100644 src/test/java/io/litmuschaos/IntegrationTestConfig.java create mode 100644 src/test/java/io/litmuschaos/util/ExperimentRunPollerTest.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6ab69c..f20224b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,4 +29,39 @@ Before you build this project, you need to generate GraphQL client codes. All yo mvn generate-sources ``` -After generated sources, you can check out sources in the `/target/generated-sources` \ No newline at end of file +After generated sources, you can check out sources in the `/target/generated-sources` + +### **3. Run tests** + +The default test suite must be deterministic and should not require a running +LitmusChaos server: + +```git +./mvnw test +./mvnw package +./mvnw javadoc:javadoc +``` + +Tests that call a live LitmusChaos server are tagged as `integration` and are +excluded from the default Maven test run. Run them only when you have a local or +test LitmusChaos instance and a disposable test account/token: + +```git +export LITMUS_TEST_HOST=http://localhost:9091 +export LITMUS_TEST_TOKEN= +export LITMUS_TEST_USER_ID= +./mvnw test -Dgroups=integration -DexcludedGroups= +``` + +Tests that delete an API token also require `LITMUS_TEST_DELETE_TOKEN`. + +To verify only the experiment-run polling helper against a live LitmusChaos +server, provide a project ID and an existing experiment run ID: + +```git +export LITMUS_TEST_HOST=http://localhost:9091 +export LITMUS_TEST_TOKEN= +export LITMUS_TEST_PROJECT_ID= +export LITMUS_TEST_EXPERIMENT_RUN_ID= +./mvnw test -Dgroups=integration -DexcludedGroups= -Dtest=ExperimentRunLiveTest +``` diff --git a/pom.xml b/pom.xml index 9daa801..3331df1 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ 3.4.0 3.2.0 3.11.2 + integration @@ -154,6 +155,9 @@ org.apache.maven.plugins maven-surefire-plugin ${maven-surefire-plugin.version} + + ${excludedGroups} + @@ -207,4 +211,4 @@ - \ No newline at end of file + diff --git a/src/main/java/io/litmuschaos/LitmusClient.java b/src/main/java/io/litmuschaos/LitmusClient.java index afe0e73..bd0aafa 100644 --- a/src/main/java/io/litmuschaos/LitmusClient.java +++ b/src/main/java/io/litmuschaos/LitmusClient.java @@ -10,9 +10,11 @@ import io.litmuschaos.http.LitmusHttpClient; import io.litmuschaos.request.*; import io.litmuschaos.response.*; +import io.litmuschaos.util.ExperimentRunPoller; import okhttp3.OkHttpClient; import java.io.IOException; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -938,6 +940,42 @@ public ExperimentRun getExperimentRun(GetExperimentRunGraphQLQuery query, GetExp return graphQLClient.query(request, GET_EXPERIMENT_RUN, new TypeRef(){}); } + /** + * Waits for a chaos experiment run to reach a terminal phase. + * + * @param projectId project ID that owns the experiment run + * @param experimentRunId experiment run ID to poll + * @param timeout maximum time to wait + * @param interval delay between state checks + * @return the latest experiment run once it reaches a terminal phase + * @throws LitmusApiException when fetching fails, waiting is interrupted, or the timeout expires + * @throws IOException when fetching the experiment run fails + */ + public ExperimentRun waitForExperimentRun(String projectId, String experimentRunId, Duration timeout, + Duration interval) throws LitmusApiException, IOException { + GetExperimentRunGraphQLQuery query = new GetExperimentRunGraphQLQuery.Builder() + .projectID(projectId) + .experimentRunID(experimentRunId) + .build(); + + GetExperimentRunProjectionRoot projectionRoot = new GetExperimentRunProjectionRoot<>() + .projectID() + .experimentRunID() + .experimentID() + .experimentName() + .experimentType() + .notifyID() + .phase().root() + .resiliencyScore() + .runSequence(); + + return new ExperimentRunPoller().waitFor( + () -> getExperimentRun(query, projectionRoot), + timeout, + interval + ); + } + /** * Get the chaos experiment run stats. * @@ -1356,4 +1394,4 @@ public void close() throws Exception { this.okHttpClient.cache().close(); } } -} \ No newline at end of file +} diff --git a/src/main/java/io/litmuschaos/exception/ExperimentRunTimeoutException.java b/src/main/java/io/litmuschaos/exception/ExperimentRunTimeoutException.java new file mode 100644 index 0000000..e40c001 --- /dev/null +++ b/src/main/java/io/litmuschaos/exception/ExperimentRunTimeoutException.java @@ -0,0 +1,8 @@ +package io.litmuschaos.exception; + +public class ExperimentRunTimeoutException extends LitmusApiException { + + public ExperimentRunTimeoutException(String message) { + super(message); + } +} diff --git a/src/main/java/io/litmuschaos/util/ExperimentRunPoller.java b/src/main/java/io/litmuschaos/util/ExperimentRunPoller.java new file mode 100644 index 0000000..660e5be --- /dev/null +++ b/src/main/java/io/litmuschaos/util/ExperimentRunPoller.java @@ -0,0 +1,115 @@ +package io.litmuschaos.util; + +import io.litmuschaos.exception.ExperimentRunTimeoutException; +import io.litmuschaos.exception.LitmusApiException; +import io.litmuschaos.generated.types.ExperimentRun; +import io.litmuschaos.generated.types.ExperimentRunStatus; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +public class ExperimentRunPoller { + + private static final Set TERMINAL_PHASES = EnumSet.of( + ExperimentRunStatus.Completed, + ExperimentRunStatus.Completed_With_Error, + ExperimentRunStatus.Stopped, + ExperimentRunStatus.Skipped, + ExperimentRunStatus.Error, + ExperimentRunStatus.Timeout, + ExperimentRunStatus.Terminated + ); + + private final Clock clock; + private final Sleeper sleeper; + + public ExperimentRunPoller() { + this(Clock.systemUTC(), duration -> Thread.sleep(duration.toMillis())); + } + + ExperimentRunPoller(Clock clock, Sleeper sleeper) { + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.sleeper = Objects.requireNonNull(sleeper, "sleeper must not be null"); + } + + /** + * Waits until an experiment run reaches a terminal phase. + * + * @param fetcher fetches the latest experiment run state + * @param timeout maximum time to wait + * @param interval delay between state checks + * @return the latest experiment run once it reaches a terminal phase + * @throws IOException when fetching the experiment run fails + * @throws LitmusApiException when fetching fails, waiting is interrupted, or the timeout expires + */ + public ExperimentRun waitFor(ExperimentRunFetcher fetcher, Duration timeout, Duration interval) + throws IOException, LitmusApiException { + validate(fetcher, timeout, interval); + + Instant deadline = clock.instant().plus(timeout); + while (true) { + ExperimentRun experimentRun = fetcher.fetch(); + if (experimentRun == null) { + throw new LitmusApiException("Experiment run fetcher returned null."); + } + if (isTerminal(experimentRun.getPhase())) { + return experimentRun; + } + + Instant now = clock.instant(); + if (!now.isBefore(deadline)) { + throw timeoutException(timeout); + } + + sleepUntilNextPoll(now, deadline, interval); + } + } + + public static boolean isTerminal(ExperimentRunStatus phase) { + return TERMINAL_PHASES.contains(phase); + } + + private void sleepUntilNextPoll(Instant now, Instant deadline, Duration interval) throws LitmusApiException { + Duration remaining = Duration.between(now, deadline); + Duration sleepDuration = interval.compareTo(remaining) < 0 ? interval : remaining; + try { + sleeper.sleep(sleepDuration); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LitmusApiException("Interrupted while waiting for experiment run.", e); + } + } + + private void validate(ExperimentRunFetcher fetcher, Duration timeout, Duration interval) { + Objects.requireNonNull(fetcher, "fetcher must not be null"); + Objects.requireNonNull(timeout, "timeout must not be null"); + Objects.requireNonNull(interval, "interval must not be null"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must be positive"); + } + if (interval.isZero() || interval.isNegative()) { + throw new IllegalArgumentException("interval must be positive"); + } + } + + private ExperimentRunTimeoutException timeoutException(Duration timeout) { + return new ExperimentRunTimeoutException( + "Experiment run did not reach a terminal phase within " + timeout + "." + ); + } + + @FunctionalInterface + public interface ExperimentRunFetcher { + ExperimentRun fetch() throws IOException, LitmusApiException; + } + + @FunctionalInterface + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; + } +} diff --git a/src/test/java/io/litmuschaos/AuthTest.java b/src/test/java/io/litmuschaos/AuthTest.java index d9459bf..99975cd 100644 --- a/src/test/java/io/litmuschaos/AuthTest.java +++ b/src/test/java/io/litmuschaos/AuthTest.java @@ -3,21 +3,22 @@ import io.litmuschaos.exception.LitmusApiException; import io.litmuschaos.response.CapabilityResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; +@Tag("integration") public class AuthTest { - private static final String HOST_URL = "http://localhost:3000"; - private static final String TEST_TOKEN = "Bearer token"; // Put your token here private LitmusClient authClient; @BeforeEach public void setup() { - this.authClient = new LitmusClient(HOST_URL, TEST_TOKEN); + IntegrationTestConfig.assumeConfigured(); + this.authClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); } @Test diff --git a/src/test/java/io/litmuschaos/BuildRequestTest.java b/src/test/java/io/litmuschaos/BuildRequestTest.java index e6ac43b..d63137e 100644 --- a/src/test/java/io/litmuschaos/BuildRequestTest.java +++ b/src/test/java/io/litmuschaos/BuildRequestTest.java @@ -4,21 +4,22 @@ import io.litmuschaos.request.ListProjectRequest; import io.litmuschaos.response.ListProjectsResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; +@Tag("integration") public class BuildRequestTest { - private static final String HOST_URL = "http://localhost:3000"; - private static final String TEST_TOKEN = "Bearer token"; // Put your token here private LitmusClient litmusClient; @BeforeEach public void setup() throws IOException, LitmusApiException { - this.litmusClient = new LitmusClient(HOST_URL, TEST_TOKEN); + IntegrationTestConfig.assumeConfigured(); + this.litmusClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); } @Test diff --git a/src/test/java/io/litmuschaos/ExperimentRunLiveTest.java b/src/test/java/io/litmuschaos/ExperimentRunLiveTest.java new file mode 100644 index 0000000..882aafc --- /dev/null +++ b/src/test/java/io/litmuschaos/ExperimentRunLiveTest.java @@ -0,0 +1,51 @@ +package io.litmuschaos; + +import io.litmuschaos.exception.LitmusApiException; +import io.litmuschaos.generated.types.ExperimentRun; +import io.litmuschaos.generated.types.ExperimentRunStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("integration") +class ExperimentRunLiveTest { + + private static final Set TERMINAL_STATUSES = EnumSet.of( + ExperimentRunStatus.Completed, + ExperimentRunStatus.Completed_With_Error, + ExperimentRunStatus.Stopped, + ExperimentRunStatus.Skipped, + ExperimentRunStatus.Error, + ExperimentRunStatus.Timeout, + ExperimentRunStatus.Terminated + ); + + private LitmusClient litmusClient; + + @BeforeEach + void setup() { + IntegrationTestConfig.assumeConfigured(); + litmusClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); + } + + @Test + void waitForExperimentRunReturnsTerminalExperimentRunFromLiveServer() + throws LitmusApiException, IOException { + ExperimentRun experimentRun = litmusClient.waitForExperimentRun( + IntegrationTestConfig.projectId(), + IntegrationTestConfig.experimentRunId(), + Duration.ofSeconds(30), + Duration.ofSeconds(2) + ); + + assertThat(experimentRun.getExperimentRunID()).isEqualTo(IntegrationTestConfig.experimentRunId()); + assertThat(experimentRun.getPhase()).isIn(TERMINAL_STATUSES); + } +} diff --git a/src/test/java/io/litmuschaos/IntegrationTestConfig.java b/src/test/java/io/litmuschaos/IntegrationTestConfig.java new file mode 100644 index 0000000..beeed5b --- /dev/null +++ b/src/test/java/io/litmuschaos/IntegrationTestConfig.java @@ -0,0 +1,65 @@ +package io.litmuschaos; + +import org.junit.jupiter.api.Assumptions; + +final class IntegrationTestConfig { + + private static final String HOST_ENV = "LITMUS_TEST_HOST"; + private static final String TOKEN_ENV = "LITMUS_TEST_TOKEN"; + private static final String USER_ID_ENV = "LITMUS_TEST_USER_ID"; + private static final String DELETE_TOKEN_ENV = "LITMUS_TEST_DELETE_TOKEN"; + private static final String PROJECT_ID_ENV = "LITMUS_TEST_PROJECT_ID"; + private static final String EXPERIMENT_RUN_ID_ENV = "LITMUS_TEST_EXPERIMENT_RUN_ID"; + + private IntegrationTestConfig() { + } + + static void assumeConfigured() { + Assumptions.assumeTrue( + hasText(hostUrl()) && hasText(token()), + "Set " + HOST_ENV + " and " + TOKEN_ENV + " to run integration tests." + ); + } + + static String hostUrl() { + return System.getenv(HOST_ENV); + } + + static String token() { + return System.getenv(TOKEN_ENV); + } + + static String userId() { + String userId = System.getenv(USER_ID_ENV); + Assumptions.assumeTrue(hasText(userId), "Set " + USER_ID_ENV + " to run this integration test."); + return userId; + } + + static String projectId() { + String projectId = System.getenv(PROJECT_ID_ENV); + Assumptions.assumeTrue(hasText(projectId), "Set " + PROJECT_ID_ENV + " to run this integration test."); + return projectId; + } + + static String experimentRunId() { + String experimentRunId = System.getenv(EXPERIMENT_RUN_ID_ENV); + Assumptions.assumeTrue( + hasText(experimentRunId), + "Set " + EXPERIMENT_RUN_ID_ENV + " to run this integration test." + ); + return experimentRunId; + } + + static String deleteToken() { + String deleteToken = System.getenv(DELETE_TOKEN_ENV); + Assumptions.assumeTrue( + hasText(deleteToken), + "Set " + DELETE_TOKEN_ENV + " to run this integration test." + ); + return deleteToken; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/test/java/io/litmuschaos/LitmusClientTest.java b/src/test/java/io/litmuschaos/LitmusClientTest.java index 3fd5edc..475a21c 100644 --- a/src/test/java/io/litmuschaos/LitmusClientTest.java +++ b/src/test/java/io/litmuschaos/LitmusClientTest.java @@ -6,6 +6,8 @@ import io.litmuschaos.generated.types.*; import io.litmuschaos.request.*; import io.litmuschaos.response.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -13,18 +15,22 @@ import static org.assertj.core.api.Assertions.assertThat; +@Tag("integration") class LitmusClientTest { - private static final String HOST_URL = "http://localhost:53042"; // your frontend url here - private static final String TEST_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ4OTE1ODQwNzMsInJvbGUiOiJhZG1pbiIsInVpZCI6ImI1NWVlMzQwLWZiNzMtNDAyYy1hN2QwLWUxM2QzY2JiYTczOCIsInVzZXJuYW1lIjoiYWRtaW4ifQ.1bwzyAyAgW9ba7JgqaLXEomoEer-WtfyDaSqlwAdwLlKagt9lRjgaDcSm20YprsTqvM164eOSGu7FUhlOxZ81w"; // your API token here. + private LitmusClient litmusClient; - private final LitmusClient litmusClient = new LitmusClient(HOST_URL,TEST_TOKEN); + @BeforeEach + void setup() { + IntegrationTestConfig.assumeConfigured(); + litmusClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); + } // authentication @Test void getTokens() throws IOException, LitmusApiException { - String userId = "b55ee340-fb73-402c-a7d0-e13d3cbba738"; + String userId = IntegrationTestConfig.userId(); ListTokensResponse response = litmusClient.getTokens(userId); assertThat(response).isInstanceOf(ListTokensResponse.class); } @@ -32,7 +38,7 @@ void getTokens() throws IOException, LitmusApiException { @Test void createToken() throws IOException, LitmusApiException { TokenCreateRequest request = TokenCreateRequest.builder() - .userID("b55ee340-fb73-402c-a7d0-e13d3cbba738") + .userID(IntegrationTestConfig.userId()) .name("new token name") .daysUntilExpiration(36500) .build(); @@ -43,8 +49,8 @@ void createToken() throws IOException, LitmusApiException { @Test void deleteToken() throws IOException, LitmusApiException { TokenDeleteRequest request = TokenDeleteRequest.builder() - .token("eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjQ4OTE5Mzg2NTksInJvbGUiOiJhZG1pbiIsInVpZCI6ImI1NWVlMzQwLWZiNzMtNDAyYy1hN2QwLWUxM2QzY2JiYTczOCIsInVzZXJuYW1lIjoiYWRtaW4ifQ.5iio28gGfk9PVzvDmMGenwlKvm4VTCPgSXama5FfaG1-6fCPFMMbGCymq2_4kHYsVITYyZfLnYDw-ywAYEsP2Q") - .userID("b55ee340-fb73-402c-a7d0-e13d3cbba738") + .token(IntegrationTestConfig.deleteToken()) + .userID(IntegrationTestConfig.userId()) .build(); CommonResponse response = litmusClient.deleteToken(request); assertThat(response).isInstanceOf(CommonResponse.class); @@ -52,7 +58,7 @@ void deleteToken() throws IOException, LitmusApiException { @Test void getUser() throws IOException, LitmusApiException { - String userId = "b55ee340-fb73-402c-a7d0-e13d3cbba738"; + String userId = IntegrationTestConfig.userId(); UserResponse response = litmusClient.getUser(userId); assertThat(response).isInstanceOf(UserResponse.class); } diff --git a/src/test/java/io/litmuschaos/ProjectTest.java b/src/test/java/io/litmuschaos/ProjectTest.java index 85e2b3e..094f17e 100644 --- a/src/test/java/io/litmuschaos/ProjectTest.java +++ b/src/test/java/io/litmuschaos/ProjectTest.java @@ -8,6 +8,7 @@ import io.litmuschaos.response.*; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -17,16 +18,16 @@ import static org.assertj.core.api.Assertions.assertThat; +@Tag("integration") public class ProjectTest { - private static final String HOST_URL = "http://localhost:3000"; - private static final String TEST_TOKEN = "Bearer token"; // Put your token here private static LitmusClient litmusClient; private static String projectId; @BeforeAll public static void setupClient() { - litmusClient = new LitmusClient(HOST_URL, TEST_TOKEN); + IntegrationTestConfig.assumeConfigured(); + litmusClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); } @BeforeEach diff --git a/src/test/java/io/litmuschaos/UserTest.java b/src/test/java/io/litmuschaos/UserTest.java index dd286af..551bfcb 100644 --- a/src/test/java/io/litmuschaos/UserTest.java +++ b/src/test/java/io/litmuschaos/UserTest.java @@ -8,6 +8,7 @@ import io.litmuschaos.response.CommonResponse; import io.litmuschaos.response.UserResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -16,16 +17,15 @@ import static org.assertj.core.api.Assertions.assertThat; +@Tag("integration") public class UserTest { private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; private static final SecureRandom RANDOM = new SecureRandom(); - private static final String HOST_URL = "http://localhost:3000"; private static final String CLIENT_SETUP_USERNAME = "admin"; private static final String CLIENT_SETUP_PASSWORD = "Litmus1234!"; private static final String TEST_USER_PASSWORD = "testPassword"; private static final Role TEST_USER_ROLE = Role.user; - private static final String TEST_TOKEN = "Bearer token"; // Put your token here private static final String TEST_USER_EMAIL = "test@test.com"; private static final String TEST_USER_NAME = "testName"; @@ -41,7 +41,8 @@ public String generateRandomUsername() { @BeforeEach void setup() { - litmusClient = new LitmusClient(HOST_URL, TEST_TOKEN); + IntegrationTestConfig.assumeConfigured(); + litmusClient = new LitmusClient(IntegrationTestConfig.hostUrl(), IntegrationTestConfig.token()); } @Test diff --git a/src/test/java/io/litmuschaos/util/ExperimentRunPollerTest.java b/src/test/java/io/litmuschaos/util/ExperimentRunPollerTest.java new file mode 100644 index 0000000..6778a72 --- /dev/null +++ b/src/test/java/io/litmuschaos/util/ExperimentRunPollerTest.java @@ -0,0 +1,151 @@ +package io.litmuschaos.util; + +import io.litmuschaos.exception.ExperimentRunTimeoutException; +import io.litmuschaos.exception.LitmusApiException; +import io.litmuschaos.generated.types.ExperimentRun; +import io.litmuschaos.generated.types.ExperimentRunStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayDeque; +import java.util.Queue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ExperimentRunPollerTest { + + private final MutableClock clock = new MutableClock(); + + @AfterEach + void clearInterruptedFlag() { + Thread.interrupted(); + } + + @Test + void returnsFinalRunAfterPollingUntilTerminalPhase() throws LitmusApiException, IOException { + Queue runs = new ArrayDeque<>(); + runs.add(run(ExperimentRunStatus.Queued)); + runs.add(run(ExperimentRunStatus.Running)); + ExperimentRun completedRun = run(ExperimentRunStatus.Completed); + runs.add(completedRun); + ExperimentRunPoller poller = pollerAdvancingClock(); + + ExperimentRun result = poller.waitFor(runs::remove, Duration.ofSeconds(5), Duration.ofMillis(100)); + + assertThat(result).isSameAs(completedRun); + } + + @Test + void returnsImmediatelyWhenFirstRunIsTerminal() throws LitmusApiException, IOException { + ExperimentRun completedRun = run(ExperimentRunStatus.Completed); + ExperimentRunPoller poller = pollerAdvancingClock(); + + ExperimentRun result = poller.waitFor( + () -> completedRun, + Duration.ofSeconds(5), + Duration.ofMillis(100) + ); + + assertThat(result).isSameAs(completedRun); + assertThat(clock.instant()).isEqualTo(Instant.EPOCH); + } + + @Test + void throwsWhenRunDoesNotReachTerminalPhaseBeforeTimeout() { + ExperimentRunPoller poller = pollerAdvancingClock(); + + assertThatThrownBy(() -> poller.waitFor( + () -> run(ExperimentRunStatus.Running), + Duration.ofMillis(250), + Duration.ofMillis(100) + )).isInstanceOf(ExperimentRunTimeoutException.class) + .hasMessageContaining("PT0.25S"); + } + + @Test + void returnsNonSuccessfulTerminalRun() throws LitmusApiException, IOException { + ExperimentRun erroredRun = run(ExperimentRunStatus.Completed_With_Error); + ExperimentRunPoller poller = pollerAdvancingClock(); + + ExperimentRun result = poller.waitFor( + () -> erroredRun, + Duration.ofSeconds(5), + Duration.ofMillis(100) + ); + + assertThat(result).isSameAs(erroredRun); + } + + @Test + void rejectsInvalidDurations() { + ExperimentRunPoller poller = pollerAdvancingClock(); + + assertThatThrownBy(() -> poller.waitFor( + () -> run(ExperimentRunStatus.Running), + Duration.ZERO, + Duration.ofMillis(100) + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("timeout must be positive"); + + assertThatThrownBy(() -> poller.waitFor( + () -> run(ExperimentRunStatus.Running), + Duration.ofSeconds(1), + Duration.ZERO + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("interval must be positive"); + } + + @Test + void restoresInterruptedFlagWhenSleepIsInterrupted() { + ExperimentRunPoller poller = new ExperimentRunPoller(clock, duration -> { + throw new InterruptedException("interrupted"); + }); + + assertThatThrownBy(() -> poller.waitFor( + () -> run(ExperimentRunStatus.Running), + Duration.ofSeconds(1), + Duration.ofMillis(100) + )).isInstanceOf(LitmusApiException.class) + .hasMessage("Interrupted while waiting for experiment run."); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + + private ExperimentRunPoller pollerAdvancingClock() { + return new ExperimentRunPoller(clock, clock::advance); + } + + private static ExperimentRun run(ExperimentRunStatus phase) { + ExperimentRun run = new ExperimentRun(); + run.setPhase(phase); + return run; + } + + private static class MutableClock extends Clock { + private Instant instant = Instant.EPOCH; + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + + void advance(Duration duration) { + instant = instant.plus(duration); + } + } +}