diff --git a/changelog.md b/changelog.md
index 118dbb93..0f211e6a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,17 @@
# Changelog
+## v1.16.0
+
+### Oct 28, 2026
+
+- Feat: Branch create accepts an optional `branch.publish_config.environments` list of environment names, so a branch can clone only the environments it needs. `[]` clones none; omitting `publish_config` clones all environments, as before. The list is sent unchanged, the SDK adds no length limit, and an unknown name (or a mix) returns the API's 422 error body unchanged with no branch created.
+
+## v1.15.0
+
+### Oct 21, 2026
+
+- Feat: Added the opt-in `Contentstack.Builder.setSerializeNulls(boolean)` option. When enabled, `null` values in request bodies are sent to the API instead of being dropped, so a field's value (for example a Number field on a localized entry) can be cleared. Off by default; existing behavior is unchanged.
+
## v1.14.1
### Aug 26, 2026
diff --git a/pom.xml b/pom.xml
index 056f6622..69b3a27d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
cms
jar
contentstack-management-java
- 1.14.1
+ 1.16.0
Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
API-first approach
diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java
index a4a75d22..9773a088 100644
--- a/src/main/java/com/contentstack/cms/Contentstack.java
+++ b/src/main/java/com/contentstack/cms/Contentstack.java
@@ -30,6 +30,7 @@
import com.contentstack.cms.stack.Stack;
import com.contentstack.cms.user.User;
import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
import com.contentstack.cms.core.RetryConfig;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
@@ -696,6 +697,7 @@ public static class Builder {
private Integer readTimeoutSeconds;
private Integer writeTimeoutSeconds;
private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack
+ private boolean serializeNulls = false;
private RetryConfig retryConfig = RetryConfig.defaultConfig();
/**
* Default ConnectionPool holds up to 5 idle connections which will be
@@ -744,6 +746,22 @@ public Builder setRetry(@NotNull Boolean retry) {
return this;
}
+ /**
+ * Controls whether {@code null} values in request bodies are sent to the API.
+ *
+ * By default {@code null} values are dropped before the request is sent, so
+ * {@code "field": null} behaves exactly like leaving the field out. Enable this
+ * to send {@code null} explicitly, for example to clear a Number field's value
+ * on an entry. It applies to every request made through this client.
+ *
+ * @param serializeNulls {@code true} to send {@code null} values, {@code false} (default) to drop them
+ * @return the Builder
+ */
+ public Builder setSerializeNulls(boolean serializeNulls) {
+ this.serializeNulls = serializeNulls;
+ return this;
+ }
+
/**
* Set host for client instance
*
@@ -1015,8 +1033,12 @@ private void validateClient(Contentstack contentstack) {
// of how the hostname was set (setHost, region resolution, or default).
String validatedHost = validateHostname(this.hostname);
String baseUrl = Util.PROTOCOL + "://" + validatedHost + "/" + version + "/";
+ GsonBuilder gson = new GsonBuilder();
+ if (serializeNulls) {
+ gson.serializeNulls();
+ }
this.instance = new Retrofit.Builder().baseUrl(baseUrl)
- .addConverterFactory(GsonConverterFactory.create())
+ .addConverterFactory(GsonConverterFactory.create(gson.create()))
.client(httpClient(contentstack, this.retry)).build();
contentstack.instance = this.instance;
diff --git a/src/main/java/com/contentstack/cms/stack/Branch.java b/src/main/java/com/contentstack/cms/stack/Branch.java
index fdf93b52..629351f2 100644
--- a/src/main/java/com/contentstack/cms/stack/Branch.java
+++ b/src/main/java/com/contentstack/cms/stack/Branch.java
@@ -159,6 +159,32 @@ public Call 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/ContentstackUnitTest.java b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java
index 1da49474..881913ee 100644
--- a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java
+++ b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java
@@ -2,7 +2,10 @@
import com.contentstack.cms.core.AuthInterceptor;
import com.contentstack.cms.organization.Organization;
+import com.contentstack.cms.stack.Entry;
import com.contentstack.cms.stack.Stack;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import okhttp3.Headers;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
@@ -12,7 +15,10 @@
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Assertions;
+import okio.Buffer;
+import org.json.simple.JSONObject;
import org.junit.jupiter.api.Test;
+import retrofit2.Call;
import retrofit2.Response;
import java.io.IOException;
@@ -412,4 +418,71 @@ public void testEarlyAccessHeaderEmpty() {
Assertions.assertEquals(0, client.earlyAccess.length);
}
+ @Test
+ void localizeBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException {
+ Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
+
+ JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de"));
+
+ Assertions.assertTrue(entry.has("position"));
+ Assertions.assertTrue(entry.get("position").isJsonNull());
+ }
+
+ @Test
+ void localizeBodyDropsNullFieldByDefault() throws IOException {
+ Contentstack client = new Contentstack.Builder().build();
+
+ JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de"));
+
+ Assertions.assertFalse(entry.has("position"));
+ Assertions.assertEquals("example", entry.get("title").getAsString());
+ }
+
+ @Test
+ void localizeBodyKeepsNullInsideGroupFieldWhenSerializeNullsEnabled() throws IOException {
+ Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
+ Map group = new HashMap<>();
+ group.put("label", "kept");
+ group.put("position", null);
+
+ JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("group", group)), "de-de"));
+
+ JsonObject sentGroup = entry.getAsJsonObject("group");
+ Assertions.assertTrue(sentGroup.get("position").isJsonNull());
+ Assertions.assertEquals("kept", sentGroup.get("label").getAsString());
+ }
+
+ @Test
+ void updateBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException {
+ Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
+
+ JsonObject entry = sentEntry(entryOf(client).update(entryBody(fieldsWith("position", null))));
+
+ Assertions.assertTrue(entry.has("position"));
+ Assertions.assertTrue(entry.get("position").isJsonNull());
+ }
+
+ private static Map fieldsWith(String field, Object value) {
+ Map fields = new HashMap<>();
+ fields.put("title", "example");
+ fields.put(field, value);
+ return fields;
+ }
+
+ private static JSONObject entryBody(Map fields) {
+ Map body = new HashMap<>();
+ body.put("entry", fields);
+ return new JSONObject(body);
+ }
+
+ private static Entry entryOf(Contentstack client) {
+ return client.stack("api_key", "management_token").contentType("newspage").entry("entry_uid");
+ }
+
+ private static JsonObject sentEntry(Call call) throws IOException {
+ Buffer buffer = new Buffer();
+ call.request().body().writeTo(buffer);
+ return JsonParser.parseString(buffer.readUtf8()).getAsJsonObject().getAsJsonObject("entry");
+ }
+
}
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/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..6da75aab
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java
@@ -0,0 +1,388 @@
+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;
+
+import java.io.IOException;
+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), run as a real user.
+ *
+ * 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 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 {@link #TIME}, then one letter per UID below (7 characters).
+ */
+ 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";
+
+ /** 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;
+
+ /** 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() throws Exception {
+ String email;
+ String password;
+ boolean apiKeyConfigured;
+ try {
+ 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, "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() + ")");
+ }
+ }
+
+ @AfterAll
+ static void cleanUp() {
+ 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 {
+ 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: logout failed: " + e.getLocalizedMessage());
+ }
+ }
+ }
+
+ @Test
+ @Order(1)
+ 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 = 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 = 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 = awaitBranch(UID_EMPTY);
+ 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();
+ assertEquals(422, response.code(), "an unknown environment name 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_UNKNOWN);
+ }
+
+ @Test
+ @Order(5)
+ void createWithMixedNamesIsRejectedAndCreatesNoBranch() throws Exception {
+ Response response = stack.branch()
+ .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");
+ String error = errorBodyOf(response);
+ assertTrue(error.contains("publish_config.environments"),
+ "the 422 body should name publish_config.environments");
+
+ 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}}}}. */
+ 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;
+ }
+
+ /** 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 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");
+ 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());
+ }
+
}