From f4834309916c9007ec00158822aa23f75380719e Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 21 Oct 2026 11:36:42 +0530 Subject: [PATCH 1/5] feat: add opt-in Builder.setSerializeNulls to send explicit nulls (DX-29683) The SDK serializes request bodies with a default Gson converter, which drops null values, so "field": null never reached the CMA and a field such as a Number on a localized entry could not be cleared. Add Contentstack.Builder.setSerializeNulls(boolean), off by default so existing behavior is unchanged. When enabled, explicit nulls are sent for every request made through the client. Bump version to 1.15.0 and add a changelog entry. Co-Authored-By: Claude Sonnet 5.5 --- changelog.md | 6 ++ pom.xml | 2 +- .../com/contentstack/cms/Contentstack.java | 24 +++++- .../cms/ContentstackUnitTest.java | 73 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 118dbb93..8543ea85 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 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..da74f14a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.14.1 + 1.15.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/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"); + } + } From 039b1b20936a5fa3fa5fb0556435e123a9932dea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:56:38 +0000 Subject: [PATCH 2/5] feat(branch): document and test the environment list on branch create Branch create accepts an optional branch.publish_config.environments list of environment names. The runtime already passes the body through unchanged, so this change adds the Javadoc for the three behaviors (a list, [], and omitted), unit tests that pin the request body and the mocked 422 and fetch paths, and a live BranchAPITest that creates, fetches and deletes branches against a real stack (it skips without credentials). The SDK adds no validation or length limit; the API's error body is returned unchanged. Bumps the version to 1.16.0 and adds a changelog entry. Refs: DX-31564 Co-authored-by: Raj Pandey Co-authored-by: Reeshika Hosmani Co-authored-by: Aniket Shikhare Co-authored-by: Claude --- changelog.md | 6 + pom.xml | 2 +- .../com/contentstack/cms/stack/Branch.java | 26 +++ .../com/contentstack/cms/UnitTestSuite.java | 2 + .../cms/stack/APISanityTestSuite.java | 1 + .../contentstack/cms/stack/BranchAPITest.java | 150 ++++++++++++++++++ .../cms/stack/BranchUnitTest.java | 144 ++++++++++++++++- 7 files changed, 328 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/contentstack/cms/stack/BranchAPITest.java diff --git a/changelog.md b/changelog.md index 8543ea85..0f211e6a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # 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 diff --git a/pom.xml b/pom.xml index da74f14a..69b3a27d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.15.0 + 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/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: + *

+ * 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..941b21b5 --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java @@ -0,0 +1,150 @@ +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. */ + private static final String RUN = "dx31564_" + Long.toString(System.currentTimeMillis(), 36); + private static final String UID_TWO = RUN + "_two"; + private static final String UID_EMPTY = RUN + "_empty"; + private static final String UID_UNKNOWN = RUN + "_unknown"; + private static final String UID_MIXED = RUN + "_mixed"; + + /** 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(); + assertTrue(response.isSuccessful(), "create with two environment names should succeed"); + CREATED.add(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)), + new HashSet<>(environmentsOf(response))); + } + + @Test + @Order(3) + void createWithEmptyListClonesNoEnvironments() throws Exception { + Response response = stack.branch() + .create(body(UID_EMPTY, Collections.emptyList())).execute(); + assertTrue(response.isSuccessful(), "create with an empty list should succeed"); + CREATED.add(UID_EMPTY); + + 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(); + 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"); + + 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(); + 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"); + + 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"); + JSONObject publishConfig = (JSONObject) branch.get("publish_config"); + List names = new ArrayList<>(); + for (Object name : (List) publishConfig.get("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()); + } + } From c370292604eec9b595bdecbd775e41d4a424c6a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:55:57 +0000 Subject: [PATCH 3/5] fix(branch): keep live test branch UIDs within the 15-char limit Branch UIDs allow 15 lowercase letters and digits; the long run-id UIDs would have made the create calls fail for the wrong reason. Also assert the 422 body names publish_config.environments and register created UIDs for cleanup before asserting. Refs: DX-31564 Co-authored-by: Raj Pandey Co-authored-by: Reeshika Hosmani Co-authored-by: Aniket Shikhare Co-authored-by: Claude Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01XifDUX2vy4ahCznNWQ2P3S --- .../contentstack/cms/stack/BranchAPITest.java | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java index 941b21b5..f94fd6c4 100644 --- a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java +++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java @@ -33,12 +33,21 @@ public class BranchAPITest { 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. */ - private static final String RUN = "dx31564_" + Long.toString(System.currentTimeMillis(), 36); - private static final String UID_TWO = RUN + "_two"; - private static final String UID_EMPTY = RUN + "_empty"; - private static final String UID_UNKNOWN = RUN + "_unknown"; - private static final String UID_MIXED = RUN + "_mixed"; + /** + * 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<>(); @@ -73,8 +82,8 @@ static void cleanUp() { void createWithTwoEnvironmentNames() throws IOException { Response response = stack.branch() .create(body(UID_TWO, Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION))).execute(); - assertTrue(response.isSuccessful(), "create with two environment names should succeed"); CREATED.add(UID_TWO); + assertTrue(response.isSuccessful(), "create with two environment names should succeed"); } @Test @@ -91,8 +100,8 @@ void fetchBranchCarriesTwoEnvironmentNames() throws Exception { void createWithEmptyListClonesNoEnvironments() throws Exception { Response response = stack.branch() .create(body(UID_EMPTY, Collections.emptyList())).execute(); - assertTrue(response.isSuccessful(), "create with an empty list should succeed"); 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"); @@ -106,6 +115,8 @@ void createWithUnknownNameIsRejectedAndCreatesNoBranch() throws Exception { .create(body(UID_UNKNOWN, Collections.singletonList(UNKNOWN_ENV))).execute(); 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"); @@ -118,6 +129,8 @@ void createWithMixedNamesIsRejectedAndCreatesNoBranch() throws Exception { .create(body(UID_MIXED, Arrays.asList(ENV_DEVELOPMENT, 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"), + "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"); @@ -140,9 +153,13 @@ private static JSONObject body(String uid, List environments) { 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) publishConfig.get("environments")) { + for (Object name : (List) environments) { names.add((String) name); } return names; From 75dd9820243bdcb8db340b58b6bdee0bfceeafb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:57:12 +0000 Subject: [PATCH 4/5] test(branch): clean up rejected-create UIDs too Refs: DX-31564 Co-authored-by: Raj Pandey Co-authored-by: Reeshika Hosmani Co-authored-by: Aniket Shikhare Co-authored-by: Claude --- src/test/java/com/contentstack/cms/stack/BranchAPITest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java index f94fd6c4..336efc8e 100644 --- a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java +++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java @@ -113,6 +113,7 @@ 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"), @@ -127,6 +128,7 @@ void createWithUnknownNameIsRejectedAndCreatesNoBranch() throws Exception { 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"), From 289556ed5c24d889d87b19095aa490f5856a52c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:44:34 +0000 Subject: [PATCH 5/5] 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");