From 289556ed5c24d889d87b19095aa490f5856a52c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:44:34 +0000 Subject: [PATCH] test(branch): log in with EMAIL/PASSWORD and manage own environments in BranchAPITest BranchAPITest used TestClient.getStack(), which sends the management token. Branch create answered 401 (error 161) with it, and the test assumed the 'development' and 'production' environments exist on the stack. The class now: - logs in with EMAIL / PASSWORD and uses the apiKey header only; - creates two environments whose names are unique to the run, and deletes them, with every created branch (force=true), and logs out in @AfterAll; - polls fetch until a created branch is ready (bounded, 60s, 2s steps); - treats a rejected create as no branch when fetch answers 422 with error_code 905, the same answer as a missing branch; - adds TC-23 (create without publish_config) and TC-24 (environment name in a different case gets 422). Without EMAIL, PASSWORD or apiKey in .env the class is skipped with a "not run" message. TestClient gains public EMAIL and PASSWORD constants and isApiKeyConfigured(). Refs: DX-31579 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Ef6P4M5YRs6tDbdFqkvmZc --- .../java/com/contentstack/cms/TestClient.java | 11 + .../contentstack/cms/stack/BranchAPITest.java | 321 +++++++++++++++--- 2 files changed, 281 insertions(+), 51 deletions(-) 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");