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: + *

+ * 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()); + } + }