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