fetch() {
/**
* The Create a branch request creates a new branch in a particular stack of
* your organization.
+ *
+ * Optionally set {@code branch.publish_config.environments} to a list of
+ * environment names (not UIDs) to choose which environments the branch
+ * clones:
+ *
+ * - a list of names clones only those environments;
+ * - an empty list {@code []} clones none;
+ * - omitting {@code publish_config} clones all environments, as before.
+ *
+ * The list is sent unchanged. The SDK does not validate the names or limit the
+ * list length. An unknown name, or a mix of known and unknown names, is
+ * rejected by the API with a 422; the API's error body is returned unchanged
+ * and no branch is created.
+ *
+ * Example body:
+ *
{@code
+ * JSONObject publishConfig = new JSONObject();
+ * publishConfig.put("environments", Arrays.asList("development", "staging"));
+ * JSONObject branch = new JSONObject();
+ * branch.put("uid", "branch_uid");
+ * branch.put("source", "main");
+ * branch.put("publish_config", publishConfig);
+ * JSONObject body = new JSONObject();
+ * body.put("branch", branch);
+ * stack.branch().create(body).execute();
+ * }
*
* @param body the request body
* @return Call
diff --git a/src/test/java/com/contentstack/cms/UnitTestSuite.java b/src/test/java/com/contentstack/cms/UnitTestSuite.java
index 5b84fb52..1fdf6b50 100644
--- a/src/test/java/com/contentstack/cms/UnitTestSuite.java
+++ b/src/test/java/com/contentstack/cms/UnitTestSuite.java
@@ -3,6 +3,7 @@
import com.contentstack.cms.core.AuthInterceptorTest;
import com.contentstack.cms.core.EndpointTest;
import com.contentstack.cms.stack.AssetUnitTest;
+import com.contentstack.cms.stack.BranchUnitTest;
import com.contentstack.cms.stack.EntryVariantUnitTest;
import com.contentstack.cms.stack.EnvironmentUnitTest;
import com.contentstack.cms.stack.GlobalFieldUnitTests;
@@ -33,6 +34,7 @@
AssetUnitTest.class,
EntryVariantUnitTest.class,
EnvironmentUnitTest.class,
+ BranchUnitTest.class,
GlobalFieldUnitTests.class,
LocaleUnitTest.class,
ReleaseUnitTest.class,
diff --git a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
index 06404419..13248ca0 100644
--- a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
+++ b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
@@ -11,6 +11,7 @@
EndpointAPITest.class,
TaxonomyAPITest.class,
AssetAPITest.class,
+ BranchAPITest.class,
ContentTypeAPITest.class,
EntryFieldsAPITest.class,
EnvironmentAPITest.class,
diff --git a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java
new file mode 100644
index 00000000..336efc8e
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java
@@ -0,0 +1,169 @@
+package com.contentstack.cms.stack;
+
+import com.contentstack.cms.TestClient;
+
+import okhttp3.ResponseBody;
+import org.json.simple.JSONObject;
+import org.json.simple.parser.JSONParser;
+import org.junit.jupiter.api.*;
+import retrofit2.Response;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Live checks for {@code publish_config.environments} on branch create (DX-31564).
+ *
+ * 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.
+ */
+@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";
+
+ /**
+ * 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).
+ */
+ private static final String RUN = runId();
+ 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 String runId() {
+ String time = Long.toString(System.currentTimeMillis(), 36);
+ return "b" + time.substring(time.length() - 5);
+ }
+
+ /** Branches this class created, deleted in {@link #cleanUp()}. */
+ private static final List CREATED = new ArrayList<>();
+
+ private static Stack stack;
+
+ @BeforeAll
+ static void setup() {
+ try {
+ stack = TestClient.getStack();
+ } 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.assumeFalse(TestClient.isUsingDefaultStackCredentials(),
+ "Skipping: stack credentials not set in .env");
+ }
+
+ @AfterAll
+ static void cleanUp() {
+ for (String uid : CREATED) {
+ try {
+ stack.branch(uid).delete().execute();
+ } catch (IOException e) {
+ System.out.println("Cleanup failed for branch " + uid + ": " + 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);
+ assertTrue(response.isSuccessful(), "create with two environment names should succeed");
+ }
+
+ @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)),
+ 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);
+ assertTrue(response.isSuccessful(), "create with an empty list should succeed");
+
+ Response fetched = stack.branch(UID_EMPTY).fetch().execute();
+ assertTrue(fetched.isSuccessful(), "fetch of the created branch should succeed");
+ assertTrue(environmentsOf(fetched).isEmpty(), "an empty list must come back empty");
+ }
+
+ @Test
+ @Order(4)
+ 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"),
+ "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");
+ }
+
+ @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);
+ 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"),
+ "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");
+ }
+
+ /** Builds {@code {"branch": {uid, source, publish_config: {environments}}}}. */
+ private static JSONObject body(String uid, List environments) {
+ JSONObject publishConfig = new JSONObject();
+ publishConfig.put("environments", environments);
+ JSONObject branch = new JSONObject();
+ branch.put("uid", uid);
+ branch.put("source", SOURCE);
+ branch.put("publish_config", publishConfig);
+ JSONObject root = new JSONObject();
+ root.put("branch", branch);
+ 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 publishConfig = (JSONObject) branch.get("publish_config");
+ assertNotNull(publishConfig, "the branch should carry a 'publish_config' object");
+ Object environments = publishConfig.get("environments");
+ assertNotNull(environments, "publish_config should carry an 'environments' list");
+ List names = new ArrayList<>();
+ for (Object name : (List>) environments) {
+ names.add((String) name);
+ }
+ return names;
+ }
+}
diff --git a/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java b/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java
index 6120bc27..20d88865 100644
--- a/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java
+++ b/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java
@@ -3,19 +3,29 @@
import com.contentstack.cms.TestClient;
import okhttp3.Request;
import okhttp3.ResponseBody;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.junit.jupiter.api.*;
import retrofit2.Call;
-
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.converter.gson.GsonConverterFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@Tag("unit")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class BranchUnitTest {
+public class BranchUnitTest {
protected static String AUTHTOKEN = TestClient.AUTHTOKEN;
protected static Branch branch;
@@ -310,4 +320,134 @@ void testCompare() {
assertNotNull(request.url());
}
+ // --- publish_config.environments on branch create (DX-31564) -----------------
+ // These run against a MockWebServer through a bare Retrofit, because the public
+ // Contentstack.Builder only speaks https. Retrofit and the Gson converter match
+ // what the client builds, so the request body is serialized the same way.
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() throws IOException {
+ server.shutdown();
+ }
+
+ private Branch mockedBranch(String uid) {
+ Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl(server.url("/v3/"))
+ .addConverterFactory(GsonConverterFactory.create())
+ .build();
+ return new Branch(retrofit, new HashMap(), uid);
+ }
+
+ private static JSONObject bodyWithEnvironments(List environments) {
+ JSONObject publishConfig = new JSONObject();
+ publishConfig.put("environments", environments);
+ JSONObject branchJson = new JSONObject();
+ branchJson.put("uid", "dev");
+ branchJson.put("source", "main");
+ branchJson.put("publish_config", publishConfig);
+ JSONObject root = new JSONObject();
+ root.put("branch", branchJson);
+ return root;
+ }
+
+ private static JSONObject parse(String json) throws ParseException {
+ return (JSONObject) new JSONParser().parse(json);
+ }
+
+ private static JSONObject publishConfigSent(JSONObject sent) {
+ return (JSONObject) ((JSONObject) sent.get("branch")).get("publish_config");
+ }
+
+ @Test
+ void createSendsEnvironmentNamesAsGiven() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}"));
+ Response response = mockedBranch("dev")
+ .create(bodyWithEnvironments(Arrays.asList("development", "staging"))).execute();
+ assertTrue(response.isSuccessful());
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertEquals(Arrays.asList("development", "staging"), publishConfigSent(sent).get("environments"));
+ }
+
+ @Test
+ void createSendsEmptyEnvironmentListAsEmptyArray() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}"));
+ mockedBranch("dev").create(bodyWithEnvironments(Collections.emptyList())).execute();
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertTrue(publishConfigSent(sent).containsKey("environments"));
+ assertEquals(Collections.emptyList(), publishConfigSent(sent).get("environments"));
+ }
+
+ @Test
+ void createSendsDuplicateEnvironmentNamesAsGiven() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}"));
+ List duplicates = Arrays.asList("development", "development");
+ mockedBranch("dev").create(bodyWithEnvironments(duplicates)).execute();
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertEquals(duplicates, publishConfigSent(sent).get("environments"));
+ }
+
+ @Test
+ void createSendsOneHundredFiftyEnvironmentNamesUnchanged() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}"));
+ List many = new ArrayList<>();
+ for (int i = 0; i < 150; i++) {
+ many.add("env_" + i);
+ }
+ mockedBranch("dev").create(bodyWithEnvironments(many)).execute();
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ List> sentNames = (List>) publishConfigSent(sent).get("environments");
+ assertEquals(150, sentNames.size());
+ assertEquals(many, sentNames);
+ }
+
+ @Test
+ void createSendsMixedCaseAndSpacedNamesUnchanged() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}"));
+ List names = Arrays.asList("Development", " staging ", "PRODUCTION");
+ mockedBranch("dev").create(bodyWithEnvironments(names)).execute();
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertEquals(names, publishConfigSent(sent).get("environments"));
+ }
+
+ @Test
+ void createWithoutEnvironmentListSendsBodyUnchanged() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"release\"}}"));
+ mockedBranch("release").create(parse(theJsonString)).execute();
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertEquals(parse(theJsonString), sent);
+ assertFalse(((JSONObject) sent.get("branch")).containsKey("publish_config"));
+ }
+
+ @Test
+ void createRejectedWith422ReturnsApiErrorBodyUnchanged() throws Exception {
+ String apiError = "{\"error_message\":\"Environment not found.\",\"error_code\":422,"
+ + "\"errors\":{\"publish_config.environments\":[\"staging_typo\"]}}";
+ server.enqueue(new MockResponse().setResponseCode(422).setBody(apiError));
+ Response response = mockedBranch("dev")
+ .create(bodyWithEnvironments(Arrays.asList("development", "staging_typo"))).execute();
+ assertEquals(422, response.code());
+ assertFalse(response.isSuccessful());
+ assertEquals(apiError, response.errorBody().string());
+ JSONObject sent = parse(server.takeRequest().getBody().readUtf8());
+ assertEquals(Arrays.asList("development", "staging_typo"), publishConfigSent(sent).get("environments"));
+ }
+
+ @Test
+ void fetchReturnsEnvironmentListUnchanged() throws Exception {
+ String fetched = "{\"branch\":{\"uid\":\"dev\",\"source\":\"main\","
+ + "\"publish_config\":{\"environments\":[\"development\",\"staging\"]}}}";
+ server.enqueue(new MockResponse().setResponseCode(200).setBody(fetched));
+ Response response = mockedBranch("dev").fetch().execute();
+ assertEquals("/v3/stacks/branches/dev", server.takeRequest().getPath());
+ assertEquals(fetched, response.body().string());
+ }
+
}