From 481abb2b9934ea57f9291c220654051a5e9257ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:59:07 +0000 Subject: [PATCH] feat(branch): add optional environment list to branch create Branch create can now clone only the environments a caller needs. BranchModel gains PublishConfig (BranchPublishConfigModel) with an Environments list of environment names, not UIDs. Two names clone only those two; an empty list clones none; leaving PublishConfig unset sends the same body as before and clones all environments. The SDK passes the list through unvalidated, adds no length limit, and surfaces the API's 422 error unchanged (ContentstackErrorException with message, error code and errors); no branch is created on rejection. A fetched branch carries the list the API returns. Adds the create XML docs for these behaviors, unit tests for the request body (two names, empty list, unset), the 422 path and fetch deserialization, and integration tests for two names, an empty list, an unknown name and a mix (written, not run). Bumps the version to 1.2.0 and adds a changelog entry. Refs: DX-31565 Co-authored-by: Raj Pandey Co-authored-by: Reeshika Hosmani Co-authored-by: Aniket Shikhare Co-authored-by: Claude --- CHANGELOG.md | 9 + .../Contentstack005_BranchTest.cs | 145 ++++++++++ .../Models/BranchTest.cs | 252 +++++++++++++++++- Contentstack.Management.Core/Models/Branch.cs | 12 +- .../Models/BranchModel.cs | 16 ++ Directory.Build.props | 2 +- 6 files changed, 432 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcccf60..a9b6742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [v1.2.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v1.2.0)(2026-10-28) + + - **New** + - **Branch create: optional environment list**: `BranchModel.PublishConfig` (`BranchPublishConfigModel`, with `Environments` as a list of environment names, not UIDs) lets `Branch.Create` / `CreateAsync` clone only the listed environments. `[]` clones none; leaving `PublishConfig` unset clones all environments, as before. The list is sent unchanged; an unknown name or a mix rejects with the API's 422 error (`ContentstackErrorException`, message, error code and errors unchanged) and no branch is created. + - **Branch fetch**: a fetched `BranchModel` carries `PublishConfig.Environments` as the API returns it; it is `null` when the API omits it. + - **Test** + - Unit tests for the branch create body with two names, `[]` and unset (through `Create` and `CreateAsync`), a mocked 422 rejection, and fetched branch deserialization. + - Integration tests for create with two names, `[]`, an unknown name and a mix (written; not run in this change). + ## [v1.1.1](https://github.com/contentstack/contentstack-management-dotnet/tree/v1.1.1)(2026-07-30) - **Fix** diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_BranchTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_BranchTest.cs index ffa5ab5..d55f1c9 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_BranchTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_BranchTest.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Contentstack.Management.Core.Exceptions; using Contentstack.Management.Core.Models; @@ -24,6 +27,16 @@ public class Contentstack005_BranchTest private const string TestBranchUidAsync = "dotnet_int_br_a"; // max 15 chars, alphanumeric+underscore private const string NonExistentBranchUid = "dotnet_no_br"; + // publish_config.environments: names (not UIDs) that must exist on the test stack. + // Set these to two real environment names on the stack before running the live suite. + private const string EnvironmentOne = "development"; + private const string EnvironmentTwo = "staging"; + private const string UnknownEnvironment = "dotnet_no_env_99999"; + private const string EnvTwoBranchUid = "dotnet_env_two"; // max 15 chars, alphanumeric+underscore + private const string EnvNoneBranchUid = "dotnet_env_none"; // max 15 chars, alphanumeric+underscore + private const string EnvUnknownBranchUid = "dotnet_env_unk"; // max 15 chars, alphanumeric+underscore + private const string EnvMixBranchUid = "dotnet_env_mix"; // max 15 chars, alphanumeric+underscore + [ClassInitialize] public static void ClassInitialize(TestContext context) { @@ -34,6 +47,10 @@ public static void ClassInitialize(TestContext context) // Best-effort cleanup of any branches left over from a previous test run TryDeleteBranch(TestBranchUid); TryDeleteBranch(TestBranchUidAsync); + TryDeleteBranch(EnvTwoBranchUid); + TryDeleteBranch(EnvNoneBranchUid); + TryDeleteBranch(EnvUnknownBranchUid); + TryDeleteBranch(EnvMixBranchUid); } [ClassCleanup] @@ -41,6 +58,10 @@ public static void ClassCleanup() { TryDeleteBranch(_createdBranchUid); TryDeleteBranch(_createdBranchUidAsync); + TryDeleteBranch(EnvTwoBranchUid); + TryDeleteBranch(EnvNoneBranchUid); + TryDeleteBranch(EnvUnknownBranchUid); + TryDeleteBranch(EnvMixBranchUid); try { _client?.Logout(); } catch { } _client = null; @@ -504,8 +525,132 @@ public void Test025_Should_Fail_Create_Duplicate_Branch() } } + // ---- Create with publish_config.environments --------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test026_Should_Create_Branch_With_Two_Environment_Names() + { + TestOutputLogger.LogContext("TestScenario", "Branch_Create_TwoEnvironments"); + try + { + var model = NewEnvironmentBranch(EnvTwoBranchUid, EnvironmentOne, EnvironmentTwo); + ContentstackResponse response = _stack.Branch().Create(model); + AssertLogger.IsNotNull(response.OpenJsonObjectResponse(), "response"); + AssertEnvironmentsEqual(response, "Branch_Create_TwoEnvironments", EnvironmentOne, EnvironmentTwo); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test027_Should_Create_Branch_With_Empty_Environment_List() + { + TestOutputLogger.LogContext("TestScenario", "Branch_Create_EmptyEnvironments"); + try + { + var model = NewEnvironmentBranch(EnvNoneBranchUid); + ContentstackResponse response = _stack.Branch().Create(model); + AssertLogger.IsNotNull(response.OpenJsonObjectResponse(), "response"); + // [] must read back as an empty list (present, not null), so assert it is not null first + AssertEnvironmentsEqual(response, "Branch_Create_EmptyEnvironments"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test028_Should_Reject_Create_With_Unknown_Environment_Name() + { + TestOutputLogger.LogContext("TestScenario", "Branch_Create_UnknownEnvironment"); + var model = NewEnvironmentBranch(EnvUnknownBranchUid, UnknownEnvironment); + try + { + _stack.Branch().Create(model); + AssertLogger.Fail("Expected API error for an unknown environment name"); + } + catch (ContentstackErrorException ex) + { + AssertEnvironmentRejected(ex, EnvUnknownBranchUid, "Branch_Create_UnknownEnvironment"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test029_Should_Reject_Create_With_Mix_Of_Valid_And_Unknown_Environment_Names() + { + TestOutputLogger.LogContext("TestScenario", "Branch_Create_MixedEnvironments"); + var model = NewEnvironmentBranch(EnvMixBranchUid, EnvironmentOne, UnknownEnvironment); + try + { + _stack.Branch().Create(model); + AssertLogger.Fail("Expected API error for a mix of valid and unknown environment names"); + } + catch (ContentstackErrorException ex) + { + AssertEnvironmentRejected(ex, EnvMixBranchUid, "Branch_Create_MixedEnvironments"); + } + } + // ---- Helper methods --------------------------------------------------- + private static BranchModel NewEnvironmentBranch(string uid, params string[] environments) + { + return new BranchModel + { + Uid = uid, + Source = SourceBranch, + PublishConfig = new BranchPublishConfigModel { Environments = new List(environments) } + }; + } + + private static List EnvironmentsOf(ContentstackResponse response) + { + JsonNode node = response.OpenJsonObjectResponse()["branch"]?["publish_config"]?["environments"]; + return node?.AsArray().Select(n => n!.GetValue()).ToList(); + } + + private static void AssertEnvironmentsEqual(ContentstackResponse response, string assertionName, params string[] expected) + { + List names = EnvironmentsOf(response); + AssertLogger.IsNotNull(names, "publish_config.environments"); + AssertLogger.IsTrue( + names.SequenceEqual(expected), + $"Expected environments [{string.Join(", ", expected)}] but got [{string.Join(", ", names)}]", + assertionName); + } + + private static void AssertEnvironmentRejected(ContentstackErrorException ex, string uid, string assertionName) + { + AssertLogger.IsTrue( + ex.StatusCode == (HttpStatusCode)422, + $"Expected 422, got {(int)ex.StatusCode} ({ex.StatusCode})", + assertionName); + AssertLogger.IsTrue( + ex.Errors != null && ex.Errors.ContainsKey("publish_config.environments"), + "Expected the API error to be keyed publish_config.environments", + assertionName); + + // No branch may be created when the request is rejected + bool exists; + try + { + _stack.Branch(uid).Fetch(); + exists = true; + } + catch (ContentstackErrorException) + { + exists = false; + } + AssertLogger.IsTrue(!exists, "A rejected create must not leave a branch behind", assertionName); + } + private static void AssertBranchApiError(ContentstackErrorException ex, string assertionName) { AssertLogger.IsTrue( diff --git a/Contentstack.Management.Core.Unit.Tests/Models/BranchTest.cs b/Contentstack.Management.Core.Unit.Tests/Models/BranchTest.cs index f958c36..5c9c66f 100644 --- a/Contentstack.Management.Core.Unit.Tests/Models/BranchTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Models/BranchTest.cs @@ -1,7 +1,20 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading.Tasks; using AutoFixture; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Internal; using Contentstack.Management.Core.Models; using Contentstack.Management.Core.Queryable; +using Contentstack.Management.Core.Runtime.Contexts; +using Contentstack.Management.Core.Runtime.Pipeline; +using Contentstack.Management.Core.Services; using Contentstack.Management.Core.Unit.Tests.Mokes; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -63,6 +76,243 @@ public void Should_Query_Branches_With_Params() StringAssert.Contains(_mockHandler.LastRequestUri.Query, "limit=2"); StringAssert.Contains(_mockHandler.LastRequestUri.Query, "skip=2"); } + + [TestMethod] + public void Create_Sends_Environment_Names_As_Given() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = NewBranchWithEnvironments("development", "staging"); + + StackWith(handler).Branch().Create(model); + + AssertEnvironmentsSent(handler.LastRequestBody, "development", "staging"); + } + + [TestMethod] + public async Task CreateAsync_Sends_Environment_Names_As_Given() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = NewBranchWithEnvironments("development", "staging"); + + await StackWith(handler).Branch().CreateAsync(model); + + AssertEnvironmentsSent(handler.LastRequestBody, "development", "staging"); + } + + [TestMethod] + public void Create_Sends_Empty_Environment_List_As_Empty_Array() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = NewBranchWithEnvironments(); + + StackWith(handler).Branch().Create(model); + + AssertEnvironmentsSent(handler.LastRequestBody); + } + + [TestMethod] + public async Task CreateAsync_Sends_Empty_Environment_List_As_Empty_Array() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = NewBranchWithEnvironments(); + + await StackWith(handler).Branch().CreateAsync(model); + + AssertEnvironmentsSent(handler.LastRequestBody); + } + + [TestMethod] + public void Create_Without_PublishConfig_Omits_The_Key() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = new BranchModel { Uid = "dev", Source = "main" }; + + StackWith(handler).Branch().Create(model); + + AssertNoPublishConfigSent(handler.LastRequestBody); + } + + [TestMethod] + public async Task CreateAsync_Without_PublishConfig_Omits_The_Key() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.Created, BranchJson)); + var model = new BranchModel { Uid = "dev", Source = "main" }; + + await StackWith(handler).Branch().CreateAsync(model); + + AssertNoPublishConfigSent(handler.LastRequestBody); + } + + [TestMethod] + public void Create_Rejected_With_422_Throws_With_Api_Error_Unchanged() + { + var handler = new BranchRequestHandler(ErrorResponse()); + var model = NewBranchWithEnvironments("development", "staging_typo"); + + var ex = Assert.ThrowsException(() => StackWith(handler).Branch().Create(model)); + + AssertRejectedWithApiError(ex, handler.LastRequestBody); + } + + [TestMethod] + public async Task CreateAsync_Rejected_With_422_Throws_With_Api_Error_Unchanged() + { + var handler = new BranchRequestHandler(ErrorResponse()); + var model = NewBranchWithEnvironments("development", "staging_typo"); + + var ex = await Assert.ThrowsExceptionAsync(() => StackWith(handler).Branch().CreateAsync(model)); + + AssertRejectedWithApiError(ex, handler.LastRequestBody); + } + + [TestMethod] + public void Fetch_Deserializes_Environment_List_Into_Model() + { + const string json = "{\"branch\":{\"uid\":\"dev\",\"source\":\"main\",\"publish_config\":{\"environments\":[\"development\",\"staging\"]}}}"; + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.OK, json)); + + ContentstackResponse response = StackWith(handler).Branch("dev").Fetch(); + BranchResponse fetched = response.OpenTResponse(); + + Assert.IsNotNull(fetched.Branch?.PublishConfig); + CollectionAssert.AreEqual(new List { "development", "staging" }, fetched.Branch.PublishConfig.Environments); + } + + [TestMethod] + public void Fetch_Without_PublishConfig_Leaves_It_Null() + { + var handler = new BranchRequestHandler(JsonResponse(HttpStatusCode.OK, BranchJson)); + + ContentstackResponse response = StackWith(handler).Branch("dev").Fetch(); + BranchResponse fetched = response.OpenTResponse(); + + Assert.IsNotNull(fetched.Branch); + Assert.IsNull(fetched.Branch.PublishConfig); + } + + // ---- helpers ----------------------------------------------------------- + + private const string BranchJson = "{\"branch\":{\"uid\":\"dev\",\"source\":\"main\"}}"; + + private Stack StackWith(IPipelineHandler handler) + { + var client = new ContentstackClient(); + client.ContentstackPipeline.ReplaceHandler(handler); + client.contentstackOptions.Authtoken = _fixture.Create(); + return new Stack(client, _fixture.Create()); + } + + private static BranchModel NewBranchWithEnvironments(params string[] environments) + { + return new BranchModel + { + Uid = "dev", + Source = "main", + PublishConfig = new BranchPublishConfigModel { Environments = new List(environments) } + }; + } + + private static ContentstackResponse JsonResponse(HttpStatusCode statusCode, string json) + { + return new ContentstackResponse(MockResponse.Create(statusCode, null, json), new JsonSerializerOptions()); + } + + private static HttpResponseMessage ErrorResponse() + { + const string json = "{\"error_message\":\"Environment not found.\",\"error_code\":422," + + "\"errors\":{\"publish_config.environments\":[\"staging_typo\"]}}"; + return MockResponse.Create((HttpStatusCode)422, null, json); + } + + private static JsonObject SentBranch(string body) + { + Assert.IsNotNull(body, "no request body was captured"); + return JsonNode.Parse(body)!["branch"]!.AsObject(); + } + + private static void AssertEnvironmentsSent(string body, params string[] expected) + { + JsonNode publishConfig = SentBranch(body)["publish_config"]; + Assert.IsNotNull(publishConfig, "publish_config was not sent"); + JsonNode environments = publishConfig["environments"]; + Assert.IsInstanceOfType(environments, typeof(JsonArray), "environments must be sent as a JSON array"); + CollectionAssert.AreEqual(expected, environments.AsArray().Select(n => n!.GetValue()).ToList()); + } + + private static void AssertNoPublishConfigSent(string body) + { + Assert.IsFalse(SentBranch(body).ContainsKey("publish_config"), "publish_config must be omitted when unset"); + } + + private static void AssertRejectedWithApiError(ContentstackErrorException ex, string body) + { + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, ex.StatusCode); + Assert.AreEqual("Environment not found.", ex.Message); + Assert.AreEqual("Environment not found.", ex.ErrorMessage); + Assert.AreEqual(422, ex.ErrorCode); + Assert.IsNotNull(ex.Errors); + Assert.AreEqual("[\"staging_typo\"]", JsonSerializer.Serialize(ex.Errors["publish_config.environments"])); + AssertEnvironmentsSent(body, "development", "staging_typo"); + } + + private sealed class BranchResponse + { + [JsonPropertyName("branch")] + public BranchModel Branch { get; set; } + } + + /// + /// Stands in for the HTTP handler. It builds the request exactly as production does, records the + /// JSON body that would be sent, then either returns the canned response or throws the canned error + /// through the SDK's own error factory. + /// + private sealed class BranchRequestHandler : IPipelineHandler + { + private readonly ContentstackResponse _response; + private readonly HttpResponseMessage _error; + + public BranchRequestHandler(ContentstackResponse response) + { + _response = response; + } + + public BranchRequestHandler(HttpResponseMessage error) + { + _error = error; + } + + public string LastRequestBody { get; private set; } + + public ILogManager LogManager { get; set; } + public IPipelineHandler InnerHandler { get; set; } + + public void InvokeSync(IExecutionContext executionContext, bool addAcceptMediaHeader = false, string apiVersion = null) + { + Capture(executionContext); + if (_error != null) + { + throw ContentstackErrorException.CreateException(_error); + } + executionContext.ResponseContext.httpResponse = _response; + } + + public Task InvokeAsync(IExecutionContext executionContext, bool addAcceptMediaHeader = false, string apiVersion = null) + { + Capture(executionContext); + if (_error != null) + { + throw ContentstackErrorException.CreateException(_error); + } + executionContext.ResponseContext.httpResponse = _response; + return Task.FromResult((T)executionContext.ResponseContext.httpResponse); + } + + private void Capture(IExecutionContext executionContext) + { + IContentstackService service = executionContext.RequestContext.service; + service.CreateHttpRequest(null, executionContext.RequestContext.config); + LastRequestBody = service.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + } + } } } - diff --git a/Contentstack.Management.Core/Models/Branch.cs b/Contentstack.Management.Core/Models/Branch.cs index 1fb91c9..5aededb 100644 --- a/Contentstack.Management.Core/Models/Branch.cs +++ b/Contentstack.Management.Core/Models/Branch.cs @@ -32,11 +32,15 @@ public Query Query() /// /// The Create branch call creates a new branch in a stack. + /// Set with (environment names, not UIDs) to choose which environments the branch clones: + /// a list of names clones only those environments; an empty list clones none; leaving PublishConfig unset 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 any other validation failure, + /// throws carrying the API's error message, error code and errors, and no branch is created. /// /// ///

         /// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
-        /// BranchModel model = new BranchModel() { Uid = "my-branch", Source = "main" };
+        /// BranchModel model = new BranchModel() { Uid = "my-branch", Source = "main", PublishConfig = new BranchPublishConfigModel() { Environments = new List<string> { "development", "staging" } } };
         /// ContentstackResponse contentstackResponse = client.Stack("<API_KEY>").Branch().Create(model);
         /// 
///
@@ -49,11 +53,15 @@ public override ContentstackResponse Create(BranchModel model, ParameterCollecti /// /// The Create branch call creates a new branch in a stack. + /// Set with (environment names, not UIDs) to choose which environments the branch clones: + /// a list of names clones only those environments; an empty list clones none; leaving PublishConfig unset 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 any other validation failure, + /// throws carrying the API's error message, error code and errors, and no branch is created. /// /// ///

         /// ContentstackClient client = new ContentstackClient("<AUTHTOKEN>", "<API_HOST>");
-        /// BranchModel model = new BranchModel() { Uid = "my-branch", Source = "main" };
+        /// BranchModel model = new BranchModel() { Uid = "my-branch", Source = "main", PublishConfig = new BranchPublishConfigModel() { Environments = new List<string> { "development", "staging" } } };
         /// ContentstackResponse contentstackResponse = await client.Stack("<API_KEY>").Branch().CreateAsync(model);
         /// 
///
diff --git a/Contentstack.Management.Core/Models/BranchModel.cs b/Contentstack.Management.Core/Models/BranchModel.cs index 3d95542..26d78e9 100644 --- a/Contentstack.Management.Core/Models/BranchModel.cs +++ b/Contentstack.Management.Core/Models/BranchModel.cs @@ -9,6 +9,19 @@ public class BranchAliasModel public string? Uid { get; set; } } + /// + /// Publish configuration for a branch. lists the environment names (not UIDs) to clone. + /// + public class BranchPublishConfigModel + { + /// + /// Environment names to clone into the branch. An empty list clones none; leaving the publish config unset clones all environments. + /// The list is sent unchanged; the SDK does not validate the names or limit the list length. + /// + [JsonPropertyName("environments")] + public List? Environments { get; set; } + } + public class BranchModel { [JsonPropertyName("uid")] @@ -31,6 +44,9 @@ public class BranchModel [JsonPropertyName("alias")] public List? Alias { get; set; } + + [JsonPropertyName("publish_config")] + public BranchPublishConfigModel? PublishConfig { get; set; } } } diff --git a/Directory.Build.props b/Directory.Build.props index ee78b2c..8bdd5df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.1.1 + 1.2.0