diff --git a/src/test/java/com/contentstack/cms/TestClient.java b/src/test/java/com/contentstack/cms/TestClient.java index e84a9d7e..be53c9f5 100644 --- a/src/test/java/com/contentstack/cms/TestClient.java +++ b/src/test/java/com/contentstack/cms/TestClient.java @@ -39,6 +39,12 @@ public class TestClient { public static final String ENTRY_VARIANT_LOCALE = isBlank(env.get("entryVariantLocale")) ? "en-us" : env.get("entryVariantLocale").trim(); + /** Login email for tests that log in with a real account (null when {@code EMAIL} is not set in {@code .env}). */ + public static final String EMAIL = env.get("EMAIL"); + + /** Login password for tests that log in with a real account (null when {@code PASSWORD} is not set in {@code .env}). */ + public static final String PASSWORD = env.get("PASSWORD"); + /** {@code true} if {@code value} is null, empty, or whitespace-only. */ private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); @@ -51,6 +57,11 @@ public static boolean isUsingDefaultStackCredentials() { return env.get("apiKey") == null || env.get("managementToken") == null; } + /** {@code true} when {@code apiKey} is set to a non-blank value in {@code .env}. */ + public static boolean isApiKeyConfigured() { + return !isBlank(env.get("apiKey")); + } + private static Contentstack instance; private static Stack stackInstance; diff --git a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java index 336efc8e..6da75aab 100644 --- a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java +++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java @@ -1,10 +1,15 @@ package com.contentstack.cms.stack; +import com.contentstack.cms.Contentstack; import com.contentstack.cms.TestClient; +import com.contentstack.cms.core.Util; +import com.contentstack.cms.models.LoginDetails; import okhttp3.ResponseBody; +import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; import org.junit.jupiter.api.*; import retrofit2.Response; @@ -12,99 +17,176 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import static org.junit.jupiter.api.Assertions.*; /** - * Live checks for {@code publish_config.environments} on branch create (DX-31564). + * Live checks for {@code publish_config.environments} on branch create (DX-31564), run as a real user. *

- * Needs a real stack in {@code .env}; without credentials the whole class is skipped. - * The environment names {@value #ENV_DEVELOPMENT} and {@value #ENV_PRODUCTION} must exist - * on the test stack. The source branch {@value #SOURCE} must exist too. + * The class logs in with {@code EMAIL} / {@code PASSWORD} and uses the stack's {@code apiKey} header only; + * it does not send the management token. Before the tests it creates two environments whose names are + * unique to the run, and after them it deletes every branch it created (force) and those environments, + * then logs out. The stack ends as it started. + *

+ * Needs {@code EMAIL}, {@code PASSWORD} and {@code apiKey} in {@code .env} (or the environment). When any of + * them is missing the whole class is skipped with an "not run" message, which is not a pass. + *

+ * Branch creation is asynchronous: after each successful create the test polls the branch until fetch + * succeeds. A rejected create leaves no branch, which the API reports as 422 with error_code 905 (the same + * answer as a branch that does not exist). */ @Tag("api") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class BranchAPITest { - static final String ENV_DEVELOPMENT = "development"; - static final String ENV_PRODUCTION = "production"; - static final String UNKNOWN_ENV = "no_such_env_dx31564"; static final String SOURCE = "main"; + static final String UNKNOWN_ENV = "no_such_env_dx31579"; + + /** Base-36 time stamp, 5 characters, shared by the run's branch UIDs and environment names. */ + private static final String TIME = runTime(); /** - * Unique per run, so reruns on the same stack do not collide. Branch UIDs allow at most - * 15 characters, lowercase letters and digits only: "b" plus the last 5 base-36 digits of - * the current time, then one letter per UID below (7 characters in total). + * Unique per run, so reruns on the same stack do not collide. Branch UIDs allow at most 15 characters, + * lowercase letters and digits only: "b" plus {@link #TIME}, then one letter per UID below (7 characters). */ - private static final String RUN = runId(); + private static final String RUN = "b" + TIME; private static final String UID_TWO = RUN + "a"; private static final String UID_EMPTY = RUN + "b"; private static final String UID_UNKNOWN = RUN + "c"; private static final String UID_MIXED = RUN + "d"; + private static final String UID_NO_PUBLISH_CONFIG = RUN + "e"; + private static final String UID_CASE_VARIANT = RUN + "f"; - private static String runId() { - String time = Long.toString(System.currentTimeMillis(), 36); - return "b" + time.substring(time.length() - 5); - } + /** Environments this class creates; names unique to the run, deleted in {@link #cleanUp()}. */ + static final String ENV_ONE = "dxenv1" + TIME; + static final String ENV_TWO = "dxenv2" + TIME; - /** Branches this class created, deleted in {@link #cleanUp()}. */ - private static final List CREATED = new ArrayList<>(); + /** Creation is asynchronous: fetch answers 422 with this error_code until the branch is ready. */ + private static final long PENDING_ERROR_CODE = 905L; + private static final long WAIT_MS = 60_000L; + private static final long POLL_MS = 2_000L; + /** Branches this class created (successful create only), deleted in {@link #cleanUp()}. */ + private static final List CREATED_BRANCHES = new ArrayList<>(); + /** Environments this class created, deleted in {@link #cleanUp()}. */ + private static final List CREATED_ENVIRONMENTS = new ArrayList<>(); + + private static Contentstack client; private static Stack stack; + private static boolean loggedIn; + + private static String runTime() { + String time = Long.toString(System.currentTimeMillis(), 36); + return time.substring(time.length() - 5); + } @BeforeAll - static void setup() { + static void setup() throws Exception { + String email; + String password; + boolean apiKeyConfigured; try { - stack = TestClient.getStack(); + email = TestClient.EMAIL; + password = TestClient.PASSWORD; + apiKeyConfigured = TestClient.isApiKeyConfigured(); } catch (ExceptionInInitializerError | NoClassDefFoundError e) { // TestClient loads .env in its static initializer; without it the class cannot load. - Assumptions.assumeTrue(false, "Skipping: TestClient could not load .env"); + Assumptions.assumeTrue(false, "not run: TestClient could not load .env"); + return; + } + Assumptions.assumeTrue(isSet(email), "not run: EMAIL is not set in .env"); + Assumptions.assumeTrue(isSet(password), "not run: PASSWORD is not set in .env"); + Assumptions.assumeTrue(apiKeyConfigured, "not run: apiKey is not set in .env"); + + client = new Contentstack.Builder().setHost(TestClient.DEV_HOST).build(); + Response login = client.login(email, password); + assertTrue(login.isSuccessful(), "login with EMAIL / PASSWORD should succeed"); + loggedIn = true; + + // Api key header only. stack(String) would treat a key that does not start with "blt" as a branch. + HashMap headers = new HashMap<>(); + headers.put(Util.API_KEY, TestClient.API_KEY); + stack = client.stack(headers); + + for (String name : Arrays.asList(ENV_ONE, ENV_TWO)) { + Response created = stack.environment().create(environmentBody(name)).execute(); + if (created.isSuccessful()) { + CREATED_ENVIRONMENTS.add(name); + } + assertTrue(created.isSuccessful(), "creating environment " + name + " should succeed (HTTP " + + created.code() + ")"); } - Assumptions.assumeFalse(TestClient.isUsingDefaultStackCredentials(), - "Skipping: stack credentials not set in .env"); } @AfterAll static void cleanUp() { - for (String uid : CREATED) { + if (stack != null) { + for (String uid : CREATED_BRANCHES) { + try { + Response deleted = stack.branch(uid).addParam("force", true).delete().execute(); + if (!deleted.isSuccessful()) { + System.out.println("Cleanup: branch " + uid + " was not deleted (HTTP " + deleted.code() + ")"); + } + } catch (IOException e) { + System.out.println("Cleanup failed for branch " + uid + ": " + e.getLocalizedMessage()); + } + } + for (String name : CREATED_ENVIRONMENTS) { + try { + Response deleted = stack.environment(name).delete().execute(); + if (!deleted.isSuccessful()) { + System.out.println("Cleanup: environment " + name + " was not deleted (HTTP " + + deleted.code() + ")"); + } + } catch (IOException e) { + System.out.println("Cleanup failed for environment " + name + ": " + e.getLocalizedMessage()); + } + } + } + if (client != null && loggedIn) { try { - stack.branch(uid).delete().execute(); + Response out = client.user().logout().execute(); + if (!out.isSuccessful()) { + System.out.println("Cleanup: logout was not successful (HTTP " + out.code() + ")"); + } } catch (IOException e) { - System.out.println("Cleanup failed for branch " + uid + ": " + e.getLocalizedMessage()); + System.out.println("Cleanup: logout failed: " + e.getLocalizedMessage()); } } } @Test @Order(1) - void createWithTwoEnvironmentNames() throws IOException { - Response response = stack.branch() - .create(body(UID_TWO, Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION))).execute(); - CREATED.add(UID_TWO); + void createWithTwoEnvironmentNames() throws Exception { + Response response = createBranch(UID_TWO, body(UID_TWO, Arrays.asList(ENV_ONE, ENV_TWO))); assertTrue(response.isSuccessful(), "create with two environment names should succeed"); + assertEquals(UID_TWO, branchUidIn(response.body().string()), + "the create response should carry the branch uid"); + awaitBranch(UID_TWO); } @Test @Order(2) void fetchBranchCarriesTwoEnvironmentNames() throws Exception { - Response response = stack.branch(UID_TWO).fetch().execute(); - assertTrue(response.isSuccessful(), "fetch of the created branch should succeed"); - assertEquals(new HashSet<>(Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION)), + Response response = awaitBranch(UID_TWO); + assertEquals(new HashSet<>(Arrays.asList(ENV_ONE, ENV_TWO)), new HashSet<>(environmentsOf(response))); } @Test @Order(3) void createWithEmptyListClonesNoEnvironments() throws Exception { - Response response = stack.branch() - .create(body(UID_EMPTY, Collections.emptyList())).execute(); - CREATED.add(UID_EMPTY); + Response response = createBranch(UID_EMPTY, + body(UID_EMPTY, Collections.emptyList())); assertTrue(response.isSuccessful(), "create with an empty list should succeed"); + assertEquals(UID_EMPTY, branchUidIn(response.body().string()), + "the create response should carry the branch uid"); - Response fetched = stack.branch(UID_EMPTY).fetch().execute(); - assertTrue(fetched.isSuccessful(), "fetch of the created branch should succeed"); + Response fetched = awaitBranch(UID_EMPTY); assertTrue(environmentsOf(fetched).isEmpty(), "an empty list must come back empty"); } @@ -113,29 +195,141 @@ void createWithEmptyListClonesNoEnvironments() throws Exception { void createWithUnknownNameIsRejectedAndCreatesNoBranch() throws Exception { Response response = stack.branch() .create(body(UID_UNKNOWN, Collections.singletonList(UNKNOWN_ENV))).execute(); - CREATED.add(UID_UNKNOWN); assertEquals(422, response.code(), "an unknown environment name should give the API's 422"); - assertNotNull(response.errorBody(), "the API's error body should be present"); - assertTrue(response.errorBody().string().contains("publish_config.environments"), + String error = errorBodyOf(response); + assertTrue(error.contains("publish_config.environments"), "the 422 body should name publish_config.environments"); - Response fetched = stack.branch(UID_UNKNOWN).fetch().execute(); - assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create"); + assertNoBranch(UID_UNKNOWN); } @Test @Order(5) void createWithMixedNamesIsRejectedAndCreatesNoBranch() throws Exception { Response response = stack.branch() - .create(body(UID_MIXED, Arrays.asList(ENV_DEVELOPMENT, UNKNOWN_ENV))).execute(); - CREATED.add(UID_MIXED); + .create(body(UID_MIXED, Arrays.asList(ENV_ONE, UNKNOWN_ENV))).execute(); assertEquals(422, response.code(), "a mix with an unknown name should give the API's 422"); - assertNotNull(response.errorBody(), "the API's error body should be present"); - assertTrue(response.errorBody().string().contains("publish_config.environments"), + String error = errorBodyOf(response); + assertTrue(error.contains("publish_config.environments"), "the 422 body should name publish_config.environments"); - Response fetched = stack.branch(UID_MIXED).fetch().execute(); - assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create"); + assertNoBranch(UID_MIXED); + } + + @Test + @Order(6) + void createWithoutPublishConfigCreatesBranchWithoutEnvironments() throws Exception { + Response response = createBranch(UID_NO_PUBLISH_CONFIG, bodyWithoutPublishConfig(UID_NO_PUBLISH_CONFIG)); + assertTrue(response.isSuccessful(), "create without a publish_config list should succeed"); + assertEquals(UID_NO_PUBLISH_CONFIG, branchUidIn(response.body().string()), + "the create response should carry the branch uid"); + + Response fetched = awaitBranch(UID_NO_PUBLISH_CONFIG); + JSONObject branch = branchOf(fetched.body().string()); + Object publishConfig = branch.get("publish_config"); + if (publishConfig != null) { + assertNull(((JSONObject) publishConfig).get("environments"), + "a branch created without a list should carry no environments"); + } + } + + @Test + @Order(7) + void createWithEnvironmentNameInOtherCaseIsRejectedAndCreatesNoBranch() throws Exception { + String variant = ENV_ONE.toUpperCase(Locale.ROOT); + assertNotEquals(ENV_ONE, variant, "the case variant must differ from the real environment name"); + + Response response = stack.branch() + .create(body(UID_CASE_VARIANT, Collections.singletonList(variant))).execute(); + assertEquals(422, response.code(), "an environment name in a different case should give the API's 422"); + String error = errorBodyOf(response); + assertTrue(error.contains("publish_config.environments"), + "the 422 body should name publish_config.environments"); + + assertNoBranch(UID_CASE_VARIANT); + } + + /** + * Creates a branch and records its uid for cleanup only when the API accepted it. + */ + private static Response createBranch(String uid, JSONObject body) throws IOException { + Response response = stack.branch().create(body).execute(); + if (response.isSuccessful()) { + CREATED_BRANCHES.add(uid); + } + return response; + } + + /** + * Polls fetch until the branch is ready. Only the pending answer (422 with error_code 905) is retried; + * any other failure, or running past {@link #WAIT_MS}, fails the test with a message. + */ + private static Response awaitBranch(String uid) throws Exception { + long deadline = System.currentTimeMillis() + WAIT_MS; + while (true) { + Response fetched = stack.branch(uid).fetch().execute(); + if (fetched.isSuccessful()) { + return fetched; + } + String error = errorBodyOf(fetched); + boolean pending = fetched.code() == 422 && errorCodeOf(error) == PENDING_ERROR_CODE; + if (!pending) { + fail("fetch of branch " + uid + " failed with HTTP " + fetched.code() + ": " + error); + } + if (System.currentTimeMillis() >= deadline) { + fail("branch " + uid + " was not ready within " + (WAIT_MS / 1000) + + "s (still HTTP 422, error_code " + PENDING_ERROR_CODE + ")"); + } + Thread.sleep(POLL_MS); + } + } + + /** + * A rejected create leaves no branch: fetch answers 422 with error_code 905, the same as a branch that + * does not exist. + */ + private static void assertNoBranch(String uid) throws IOException { + Response fetched = stack.branch(uid).fetch().execute(); + String error = errorBodyOf(fetched); + assertEquals(422, fetched.code(), "no branch should exist after a rejected create (fetch of " + uid + ")"); + assertEquals(PENDING_ERROR_CODE, errorCodeOf(error), + "a missing branch should answer error_code 905 after a rejected create"); + } + + private static boolean isSet(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** Reads an error body once; empty when the response has none. */ + private static String errorBodyOf(Response response) throws IOException { + return response.errorBody() == null ? "" : response.errorBody().string(); + } + + /** The error_code of a Contentstack error body, or -1 when it is absent or not JSON. */ + private static long errorCodeOf(String body) { + try { + Object json = new JSONParser().parse(body); + if (json instanceof JSONObject) { + Object code = ((JSONObject) json).get("error_code"); + if (code instanceof Number) { + return ((Number) code).longValue(); + } + } + } catch (ParseException e) { + // not JSON: fall through + } + return -1L; + } + + private static JSONObject branchOf(String body) throws ParseException { + JSONObject json = (JSONObject) new JSONParser().parse(body); + JSONObject branch = (JSONObject) json.get("branch"); + assertNotNull(branch, "the response should carry a 'branch' object"); + return branch; + } + + private static String branchUidIn(String body) throws ParseException { + return (String) branchOf(body).get("uid"); } /** Builds {@code {"branch": {uid, source, publish_config: {environments}}}}. */ @@ -151,11 +345,36 @@ private static JSONObject body(String uid, List environments) { return root; } + /** Builds {@code {"branch": {uid, source}}}: no publish_config at all. */ + private static JSONObject bodyWithoutPublishConfig(String uid) { + JSONObject branch = new JSONObject(); + branch.put("uid", uid); + branch.put("source", SOURCE); + JSONObject root = new JSONObject(); + root.put("branch", branch); + return root; + } + + /** Builds the environment create body: one en-us URL, content deployed. */ + private static JSONObject environmentBody(String name) { + JSONObject url = new JSONObject(); + url.put("locale", "en-us"); + url.put("url", "http://example.com"); + JSONArray urls = new JSONArray(); + urls.add(url); + JSONObject environment = new JSONObject(); + environment.put("name", name); + environment.put("servers", new JSONArray()); + environment.put("urls", urls); + environment.put("deploy_content", true); + JSONObject root = new JSONObject(); + root.put("environment", environment); + return root; + } + /** Reads {@code branch.publish_config.environments} from a fetch response. */ private static List environmentsOf(Response response) throws Exception { - JSONObject json = (JSONObject) new JSONParser().parse(response.body().string()); - JSONObject branch = (JSONObject) json.get("branch"); - assertNotNull(branch, "the fetch response should carry a 'branch' object"); + JSONObject branch = branchOf(response.body().string()); JSONObject publishConfig = (JSONObject) branch.get("publish_config"); assertNotNull(publishConfig, "the branch should carry a 'publish_config' object"); Object environments = publishConfig.get("environments");