diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62ca689..b2fde24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [v0.8.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.8.0)
+ - Feat
+ - **Entry Variant support**
+ - `EntryVariant` model for create, fetch, find, update, and delete on entry variant endpoints
+ - `Entry.Variant(uid)` to access variant operations for a given entry
+ - Publish with variants: `PublishVariant`, `PublishVariantRules`, and `Variants` / `VariantRules` on `PublishUnpublishDetails`; serialization updated in `PublishUnpublishService`
+ - Unit tests for `EntryVariant` and publish payload serialization; integration tests (`Contentstack021_EntryVariantTest`) for Product Banner lifecycle and negative cases
+
## [v0.7.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.7.0)
- Feat
- **Bulk publish/unpublish: query parameters (DX-3233)**
diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack015_BulkOperationTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack015_BulkOperationTest.cs
index 9c61f86..339ecba 100644
--- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack015_BulkOperationTest.cs
+++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack015_BulkOperationTest.cs
@@ -1789,6 +1789,9 @@ public class SimpleEntry : IEntry
{
[JsonProperty(propertyName: "title")]
public string Title { get; set; }
+
+ [JsonProperty(propertyName: "_variant")]
+ public object Variant { get; set; }
}
public class EntryInfo
diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack018_EnvironmentTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack018_EnvironmentTest.cs
new file mode 100644
index 0000000..d763b2a
--- /dev/null
+++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack018_EnvironmentTest.cs
@@ -0,0 +1,471 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using Contentstack.Management.Core.Exceptions;
+using Contentstack.Management.Core.Models;
+using Contentstack.Management.Core.Tests.Helpers;
+using Contentstack.Management.Core.Tests.Model;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Contentstack.Management.Core.Tests.IntegrationTest
+{
+ [TestClass]
+ [DoNotParallelize]
+ public class Contentstack018_EnvironmentTest
+ {
+ ///
+ /// Name that should not exist on any stack (for negative-path tests).
+ ///
+ private const string NonExistentEnvironmentName = "nonexistent_environment_name";
+
+ private static ContentstackClient _client;
+ private Stack _stack;
+
+ [ClassInitialize]
+ public static void ClassInitialize(TestContext context)
+ {
+ _client = Contentstack.CreateAuthenticatedClient();
+ }
+
+ [ClassCleanup]
+ public static void ClassCleanup()
+ {
+ try { _client?.Logout(); } catch { }
+ _client = null;
+ }
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ StackResponse response = StackResponse.getStack(_client.serializer);
+ _stack = _client.Stack(response.Stack.APIKey);
+ }
+
+ private static EnvironmentModel BuildModel(string uniqueName)
+ {
+ return new EnvironmentModel
+ {
+ Name = uniqueName,
+ Urls = new List
+ {
+ new LocalesUrl
+ {
+ Locale = "en-us",
+ Url = "https://example.com"
+ }
+ },
+ DeployContent = true
+ };
+ }
+
+ private static string ParseEnvironmentName(ContentstackResponse response)
+ {
+ var jo = response.OpenJObjectResponse();
+ return jo?["environment"]?["name"]?.ToString();
+ }
+
+ private void SafeDelete(string environmentName)
+ {
+ if (string.IsNullOrEmpty(environmentName))
+ {
+ return;
+ }
+
+ try
+ {
+ _stack.Environment(environmentName).Delete();
+ }
+ catch
+ {
+ // Best-effort cleanup; ignore if already deleted or API error
+ }
+ }
+
+ private static bool EnvironmentsArrayContainsName(JArray environments, string name)
+ {
+ if (environments == null || string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ return environments.Any(e => e["name"]?.ToString() == name);
+ }
+
+ #region A — Sync happy path
+
+ [TestMethod]
+ public void Test001_Should_Create_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_Environment_Sync");
+ string environmentName = null;
+ string name = $"env_sync_create_{Guid.NewGuid():N}";
+ try
+ {
+ var model = BuildModel(name);
+ ContentstackResponse response = _stack.Environment().Create(model);
+
+ AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create environment should succeed", "CreateSyncSuccess");
+ environmentName = ParseEnvironmentName(response);
+ AssertLogger.IsNotNull(environmentName, "environment name");
+ AssertLogger.AreEqual(name, environmentName, "Parsed name should match request", "ParsedEnvironmentName");
+
+ var jo = response.OpenJObjectResponse();
+ AssertLogger.AreEqual(name, jo["environment"]?["name"]?.ToString(), "Response name should match", "EnvironmentName");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public void Test002_Should_Fetch_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test002_Should_Fetch_Environment_Sync");
+ string environmentName = null;
+ string name = $"env_sync_fetch_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Environment().Create(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForFetch");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+ AssertLogger.AreEqual(name, environmentName, "Parsed name should match create request", "CreateNameMatch");
+
+ string expectedUid = createResponse.OpenJObjectResponse()?["environment"]?["uid"]?.ToString();
+
+ ContentstackResponse fetchResponse = _stack.Environment(name).Fetch();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "Fetch should succeed", "FetchSyncSuccess");
+
+ var env = fetchResponse.OpenJObjectResponse()?["environment"];
+ AssertLogger.AreEqual(name, env?["name"]?.ToString(), "Fetched name should match", "FetchedName");
+ AssertLogger.AreEqual(expectedUid, env?["uid"]?.ToString(), "Fetched uid should match create response", "FetchedUid");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public void Test003_Should_Query_Environments_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test003_Should_Query_Environments_Sync");
+ string environmentName = null;
+ string name = $"env_sync_query_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Environment().Create(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForQuery");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+
+ ContentstackResponse queryResponse = _stack.Environment().Query().Find();
+ AssertLogger.IsTrue(queryResponse.IsSuccessStatusCode, "Query Find should succeed", "QueryFindSuccess");
+
+ var environments = queryResponse.OpenJObjectResponse()?["environments"] as JArray;
+ AssertLogger.IsNotNull(environments, "environments array");
+ AssertLogger.IsTrue(
+ EnvironmentsArrayContainsName(environments, name),
+ "Query result should contain created environment name",
+ "ContainsName");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public void Test004_Should_Update_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test004_Should_Update_Environment_Sync");
+ string environmentNameForCleanup = null;
+ string originalName = $"env_sync_update_{Guid.NewGuid():N}";
+ string updatedName = $"{originalName}_updated";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Environment().Create(BuildModel(originalName));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForUpdate");
+ string createdName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(createdName, "name after create");
+ AssertLogger.AreEqual(originalName, createdName, "Parsed name should match create request", "CreateNameMatch");
+
+ var updateModel = BuildModel(updatedName);
+ ContentstackResponse updateResponse = _stack.Environment(originalName).Update(updateModel);
+ AssertLogger.IsTrue(updateResponse.IsSuccessStatusCode, "Update should succeed", "UpdateSyncSuccess");
+
+ environmentNameForCleanup = updatedName;
+
+ ContentstackResponse fetchResponse = _stack.Environment(updatedName).Fetch();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "Fetch after update should succeed", "FetchAfterUpdate");
+ var env = fetchResponse.OpenJObjectResponse()?["environment"];
+ AssertLogger.AreEqual(updatedName, env?["name"]?.ToString(), "Name should reflect update", "UpdatedName");
+ }
+ finally
+ {
+ SafeDelete(environmentNameForCleanup ?? originalName);
+ }
+ }
+
+ [TestMethod]
+ public void Test005_Should_Delete_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test005_Should_Delete_Environment_Sync");
+ string environmentName = null;
+ string name = $"env_sync_delete_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Environment().Create(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForDelete");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+
+ ContentstackResponse deleteResponse = _stack.Environment(name).Delete();
+ AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete should succeed", "DeleteSyncSuccess");
+
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Environment(name).Fetch(),
+ "FetchAfterDelete",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+
+ environmentName = null;
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ #endregion
+
+ #region B — Async happy path
+
+ [TestMethod]
+ public async Task Test006_Should_Create_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test006_Should_Create_Environment_Async");
+ string environmentName = null;
+ string name = $"env_async_create_{Guid.NewGuid():N}";
+ try
+ {
+ var model = BuildModel(name);
+ ContentstackResponse response = await _stack.Environment().CreateAsync(model);
+
+ AssertLogger.IsTrue(response.IsSuccessStatusCode, "CreateAsync should succeed", "CreateAsyncSuccess");
+ environmentName = ParseEnvironmentName(response);
+ AssertLogger.IsNotNull(environmentName, "environment name");
+ AssertLogger.AreEqual(name, environmentName, "Parsed name should match request", "ParsedEnvironmentName");
+
+ var jo = response.OpenJObjectResponse();
+ AssertLogger.AreEqual(name, jo["environment"]?["name"]?.ToString(), "Response name should match", "EnvironmentName");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test007_Should_Fetch_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test007_Should_Fetch_Environment_Async");
+ string environmentName = null;
+ string name = $"env_async_fetch_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Environment().CreateAsync(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForFetchAsync");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+ AssertLogger.AreEqual(name, environmentName, "Parsed name should match create request", "CreateNameMatch");
+
+ string expectedUid = createResponse.OpenJObjectResponse()?["environment"]?["uid"]?.ToString();
+
+ ContentstackResponse fetchResponse = await _stack.Environment(name).FetchAsync();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "FetchAsync should succeed", "FetchAsyncSuccess");
+
+ var env = fetchResponse.OpenJObjectResponse()?["environment"];
+ AssertLogger.AreEqual(name, env?["name"]?.ToString(), "Fetched name should match", "FetchedName");
+ AssertLogger.AreEqual(expectedUid, env?["uid"]?.ToString(), "Fetched uid should match create response", "FetchedUid");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test008_Should_Query_Environments_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test008_Should_Query_Environments_Async");
+ string environmentName = null;
+ string name = $"env_async_query_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Environment().CreateAsync(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForQueryAsync");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+
+ ContentstackResponse queryResponse = await _stack.Environment().Query().FindAsync();
+ AssertLogger.IsTrue(queryResponse.IsSuccessStatusCode, "Query FindAsync should succeed", "QueryFindAsyncSuccess");
+
+ var environments = queryResponse.OpenJObjectResponse()?["environments"] as JArray;
+ AssertLogger.IsNotNull(environments, "environments array");
+ AssertLogger.IsTrue(
+ EnvironmentsArrayContainsName(environments, name),
+ "Query result should contain created environment name",
+ "ContainsName");
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test009_Should_Update_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test009_Should_Update_Environment_Async");
+ string environmentNameForCleanup = null;
+ string originalName = $"env_async_update_{Guid.NewGuid():N}";
+ string updatedName = $"{originalName}_updated";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Environment().CreateAsync(BuildModel(originalName));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForUpdateAsync");
+ string createdName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(createdName, "name after create");
+ AssertLogger.AreEqual(originalName, createdName, "Parsed name should match create request", "CreateNameMatch");
+
+ var updateModel = BuildModel(updatedName);
+ ContentstackResponse updateResponse = await _stack.Environment(originalName).UpdateAsync(updateModel);
+ AssertLogger.IsTrue(updateResponse.IsSuccessStatusCode, "UpdateAsync should succeed", "UpdateAsyncSuccess");
+
+ environmentNameForCleanup = updatedName;
+
+ ContentstackResponse fetchResponse = await _stack.Environment(updatedName).FetchAsync();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "FetchAsync after update should succeed", "FetchAsyncAfterUpdate");
+ var env = fetchResponse.OpenJObjectResponse()?["environment"];
+ AssertLogger.AreEqual(updatedName, env?["name"]?.ToString(), "Name should reflect update", "UpdatedName");
+ }
+ finally
+ {
+ SafeDelete(environmentNameForCleanup ?? originalName);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test010_Should_Delete_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test010_Should_Delete_Environment_Async");
+ string environmentName = null;
+ string name = $"env_async_delete_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Environment().CreateAsync(BuildModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForDeleteAsync");
+ environmentName = ParseEnvironmentName(createResponse);
+ AssertLogger.IsNotNull(environmentName, "name after create");
+
+ ContentstackResponse deleteResponse = await _stack.Environment(name).DeleteAsync();
+ AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "DeleteAsync should succeed", "DeleteAsyncSuccess");
+
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Environment(name).FetchAsync(),
+ "FetchAsyncAfterDelete",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+
+ environmentName = null;
+ }
+ finally
+ {
+ SafeDelete(environmentName ?? name);
+ }
+ }
+
+ #endregion
+
+ #region C — Sync negative path
+
+ [TestMethod]
+ public void Test011_Should_Fail_Fetch_NonExistent_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test011_Should_Fail_Fetch_NonExistent_Environment_Sync");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Environment(NonExistentEnvironmentName).Fetch(),
+ "FetchNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public void Test012_Should_Fail_Update_NonExistent_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test012_Should_Fail_Update_NonExistent_Environment_Sync");
+ var model = BuildModel($"env_nonexistent_update_{Guid.NewGuid():N}");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Environment(NonExistentEnvironmentName).Update(model),
+ "UpdateNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public void Test013_Should_Fail_Delete_NonExistent_Environment_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test013_Should_Fail_Delete_NonExistent_Environment_Sync");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Environment(NonExistentEnvironmentName).Delete(),
+ "DeleteNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ #endregion
+
+ #region D — Async negative path
+
+ [TestMethod]
+ public async Task Test014_Should_Fail_Fetch_NonExistent_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test014_Should_Fail_Fetch_NonExistent_Environment_Async");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Environment(NonExistentEnvironmentName).FetchAsync(),
+ "FetchNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public async Task Test015_Should_Fail_Update_NonExistent_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test015_Should_Fail_Update_NonExistent_Environment_Async");
+ var model = BuildModel($"env_nonexistent_update_async_{Guid.NewGuid():N}");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Environment(NonExistentEnvironmentName).UpdateAsync(model),
+ "UpdateNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public async Task Test016_Should_Fail_Delete_NonExistent_Environment_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test016_Should_Fail_Delete_NonExistent_Environment_Async");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Environment(NonExistentEnvironmentName).DeleteAsync(),
+ "DeleteNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ #endregion
+ }
+}
diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack019_RoleTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack019_RoleTest.cs
new file mode 100644
index 0000000..816ae7b
--- /dev/null
+++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack019_RoleTest.cs
@@ -0,0 +1,459 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using Contentstack.Management.Core.Models;
+using Contentstack.Management.Core.Tests.Helpers;
+using Contentstack.Management.Core.Tests.Model;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Contentstack.Management.Core.Tests.IntegrationTest
+{
+ [TestClass]
+ [DoNotParallelize]
+ public class Contentstack019_RoleTest
+ {
+ ///
+ /// UID that should not exist on any stack (for negative-path tests).
+ ///
+ private const string NonExistentRoleUid = "blt0000000000000000";
+
+ private static ContentstackClient _client;
+ private Stack _stack;
+
+ [ClassInitialize]
+ public static void ClassInitialize(TestContext context)
+ {
+ _client = Contentstack.CreateAuthenticatedClient();
+ }
+
+ [ClassCleanup]
+ public static void ClassCleanup()
+ {
+ try { _client?.Logout(); } catch { }
+ _client = null;
+ }
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ StackResponse response = StackResponse.getStack(_client.serializer);
+ _stack = _client.Stack(response.Stack.APIKey);
+ }
+
+ ///
+ /// Minimal role payload: branch rule on default branch "main".
+ ///
+ private static RoleModel BuildMinimalRoleModel(string uniqueName)
+ {
+ return new RoleModel
+ {
+ Name = uniqueName,
+ Description = "Integration test role",
+ DeployContent = true,
+ Rules = new List
+ {
+ new BranchRules
+ {
+ Branches = new List { "main" }
+ }
+ }
+ };
+ }
+
+ private static string ParseRoleUid(ContentstackResponse response)
+ {
+ var jo = response.OpenJObjectResponse();
+ return jo?["role"]?["uid"]?.ToString();
+ }
+
+ private void SafeDelete(string roleUid)
+ {
+ if (string.IsNullOrEmpty(roleUid))
+ {
+ return;
+ }
+
+ try
+ {
+ _stack.Role(roleUid).Delete();
+ }
+ catch
+ {
+ // Best-effort cleanup; ignore if already deleted or API error
+ }
+ }
+
+ private static bool RolesArrayContainsUid(JArray roles, string uid)
+ {
+ if (roles == null || string.IsNullOrEmpty(uid))
+ {
+ return false;
+ }
+
+ return roles.Any(r => r["uid"]?.ToString() == uid);
+ }
+
+ #region A — Sync happy path
+
+ [TestMethod]
+ public void Test001_Should_Create_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_Role_Sync");
+ string roleUid = null;
+ string name = $"role_sync_create_{Guid.NewGuid():N}";
+ try
+ {
+ var model = BuildMinimalRoleModel(name);
+ ContentstackResponse response = _stack.Role().Create(model);
+
+ AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create role should succeed", "CreateSyncSuccess");
+ roleUid = ParseRoleUid(response);
+ AssertLogger.IsNotNull(roleUid, "role uid");
+
+ var jo = response.OpenJObjectResponse();
+ AssertLogger.AreEqual(name, jo["role"]?["name"]?.ToString(), "Response name should match", "RoleName");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public void Test002_Should_Fetch_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test002_Should_Fetch_Role_Sync");
+ string roleUid = null;
+ string name = $"role_sync_fetch_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Role().Create(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForFetch");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse fetchResponse = _stack.Role(roleUid).Fetch();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "Fetch should succeed", "FetchSyncSuccess");
+
+ var role = fetchResponse.OpenJObjectResponse()?["role"];
+ AssertLogger.AreEqual(name, role?["name"]?.ToString(), "Fetched name should match", "FetchedName");
+ AssertLogger.AreEqual(roleUid, role?["uid"]?.ToString(), "Fetched uid should match", "FetchedUid");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public void Test003_Should_Query_Roles_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test003_Should_Query_Roles_Sync");
+ string roleUid = null;
+ string name = $"role_sync_query_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Role().Create(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForQuery");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse queryResponse = _stack.Role().Query().Find();
+ AssertLogger.IsTrue(queryResponse.IsSuccessStatusCode, "Query Find should succeed", "QueryFindSuccess");
+
+ var roles = queryResponse.OpenJObjectResponse()?["roles"] as JArray;
+ AssertLogger.IsNotNull(roles, "roles array");
+ AssertLogger.IsTrue(
+ RolesArrayContainsUid(roles, roleUid),
+ "Query result should contain created role uid",
+ "ContainsUid");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public void Test004_Should_Update_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test004_Should_Update_Role_Sync");
+ string roleUid = null;
+ string originalName = $"role_sync_update_{Guid.NewGuid():N}";
+ string updatedName = $"{originalName}_updated";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Role().Create(BuildMinimalRoleModel(originalName));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForUpdate");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ var updateModel = BuildMinimalRoleModel(updatedName);
+ ContentstackResponse updateResponse = _stack.Role(roleUid).Update(updateModel);
+ AssertLogger.IsTrue(updateResponse.IsSuccessStatusCode, "Update should succeed", "UpdateSyncSuccess");
+
+ ContentstackResponse fetchResponse = _stack.Role(roleUid).Fetch();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "Fetch after update should succeed", "FetchAfterUpdate");
+ var role = fetchResponse.OpenJObjectResponse()?["role"];
+ AssertLogger.AreEqual(updatedName, role?["name"]?.ToString(), "Name should reflect update", "UpdatedName");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public void Test005_Should_Delete_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test005_Should_Delete_Role_Sync");
+ string roleUid = null;
+ string name = $"role_sync_delete_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = _stack.Role().Create(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForDelete");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse deleteResponse = _stack.Role(roleUid).Delete();
+ AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete should succeed", "DeleteSyncSuccess");
+
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Role(roleUid).Fetch(),
+ "FetchAfterDelete",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+
+ roleUid = null;
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ #endregion
+
+ #region B — Async happy path
+
+ [TestMethod]
+ public async Task Test006_Should_Create_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test006_Should_Create_Role_Async");
+ string roleUid = null;
+ string name = $"role_async_create_{Guid.NewGuid():N}";
+ try
+ {
+ var model = BuildMinimalRoleModel(name);
+ ContentstackResponse response = await _stack.Role().CreateAsync(model);
+
+ AssertLogger.IsTrue(response.IsSuccessStatusCode, "CreateAsync should succeed", "CreateAsyncSuccess");
+ roleUid = ParseRoleUid(response);
+ AssertLogger.IsNotNull(roleUid, "role uid");
+
+ var jo = response.OpenJObjectResponse();
+ AssertLogger.AreEqual(name, jo["role"]?["name"]?.ToString(), "Response name should match", "RoleName");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test007_Should_Fetch_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test007_Should_Fetch_Role_Async");
+ string roleUid = null;
+ string name = $"role_async_fetch_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Role().CreateAsync(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForFetchAsync");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse fetchResponse = await _stack.Role(roleUid).FetchAsync();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "FetchAsync should succeed", "FetchAsyncSuccess");
+
+ var role = fetchResponse.OpenJObjectResponse()?["role"];
+ AssertLogger.AreEqual(name, role?["name"]?.ToString(), "Fetched name should match", "FetchedName");
+ AssertLogger.AreEqual(roleUid, role?["uid"]?.ToString(), "Fetched uid should match", "FetchedUid");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test008_Should_Query_Roles_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test008_Should_Query_Roles_Async");
+ string roleUid = null;
+ string name = $"role_async_query_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Role().CreateAsync(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForQueryAsync");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse queryResponse = await _stack.Role().Query().FindAsync();
+ AssertLogger.IsTrue(queryResponse.IsSuccessStatusCode, "Query FindAsync should succeed", "QueryFindAsyncSuccess");
+
+ var roles = queryResponse.OpenJObjectResponse()?["roles"] as JArray;
+ AssertLogger.IsNotNull(roles, "roles array");
+ AssertLogger.IsTrue(
+ RolesArrayContainsUid(roles, roleUid),
+ "Query result should contain created role uid",
+ "ContainsUid");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test009_Should_Update_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test009_Should_Update_Role_Async");
+ string roleUid = null;
+ string originalName = $"role_async_update_{Guid.NewGuid():N}";
+ string updatedName = $"{originalName}_updated";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Role().CreateAsync(BuildMinimalRoleModel(originalName));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForUpdateAsync");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ var updateModel = BuildMinimalRoleModel(updatedName);
+ ContentstackResponse updateResponse = await _stack.Role(roleUid).UpdateAsync(updateModel);
+ AssertLogger.IsTrue(updateResponse.IsSuccessStatusCode, "UpdateAsync should succeed", "UpdateAsyncSuccess");
+
+ ContentstackResponse fetchResponse = await _stack.Role(roleUid).FetchAsync();
+ AssertLogger.IsTrue(fetchResponse.IsSuccessStatusCode, "FetchAsync after update should succeed", "FetchAsyncAfterUpdate");
+ var role = fetchResponse.OpenJObjectResponse()?["role"];
+ AssertLogger.AreEqual(updatedName, role?["name"]?.ToString(), "Name should reflect update", "UpdatedName");
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ [TestMethod]
+ public async Task Test010_Should_Delete_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test010_Should_Delete_Role_Async");
+ string roleUid = null;
+ string name = $"role_async_delete_{Guid.NewGuid():N}";
+ try
+ {
+ ContentstackResponse createResponse = await _stack.Role().CreateAsync(BuildMinimalRoleModel(name));
+ AssertLogger.IsTrue(createResponse.IsSuccessStatusCode, "Create should succeed", "CreateForDeleteAsync");
+ roleUid = ParseRoleUid(createResponse);
+ AssertLogger.IsNotNull(roleUid, "uid after create");
+
+ ContentstackResponse deleteResponse = await _stack.Role(roleUid).DeleteAsync();
+ AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "DeleteAsync should succeed", "DeleteAsyncSuccess");
+
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Role(roleUid).FetchAsync(),
+ "FetchAsyncAfterDelete",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+
+ roleUid = null;
+ }
+ finally
+ {
+ SafeDelete(roleUid);
+ }
+ }
+
+ #endregion
+
+ #region C — Sync negative path
+
+ [TestMethod]
+ public void Test011_Should_Fail_Fetch_NonExistent_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test011_Should_Fail_Fetch_NonExistent_Role_Sync");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Role(NonExistentRoleUid).Fetch(),
+ "FetchNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public void Test012_Should_Fail_Update_NonExistent_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test012_Should_Fail_Update_NonExistent_Role_Sync");
+ var model = BuildMinimalRoleModel($"role_nonexistent_update_{Guid.NewGuid():N}");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Role(NonExistentRoleUid).Update(model),
+ "UpdateNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public void Test013_Should_Fail_Delete_NonExistent_Role_Sync()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test013_Should_Fail_Delete_NonExistent_Role_Sync");
+ AssertLogger.ThrowsContentstackError(
+ () => _stack.Role(NonExistentRoleUid).Delete(),
+ "DeleteNonExistentSync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ #endregion
+
+ #region D — Async negative path
+
+ [TestMethod]
+ public async Task Test014_Should_Fail_Fetch_NonExistent_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test014_Should_Fail_Fetch_NonExistent_Role_Async");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Role(NonExistentRoleUid).FetchAsync(),
+ "FetchNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public async Task Test015_Should_Fail_Update_NonExistent_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test015_Should_Fail_Update_NonExistent_Role_Async");
+ var model = BuildMinimalRoleModel($"role_nonexistent_update_async_{Guid.NewGuid():N}");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Role(NonExistentRoleUid).UpdateAsync(model),
+ "UpdateNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ [TestMethod]
+ public async Task Test016_Should_Fail_Delete_NonExistent_Role_Async()
+ {
+ TestOutputLogger.LogContext("TestScenario", "Test016_Should_Fail_Delete_NonExistent_Role_Async");
+ await AssertLogger.ThrowsContentstackErrorAsync(
+ async () => await _stack.Role(NonExistentRoleUid).DeleteAsync(),
+ "DeleteNonExistentAsync",
+ HttpStatusCode.NotFound,
+ (HttpStatusCode)422);
+ }
+
+ #endregion
+ }
+}
diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack020_WorkflowTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack020_WorkflowTest.cs
new file mode 100644
index 0000000..a992ee9
--- /dev/null
+++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack020_WorkflowTest.cs
@@ -0,0 +1,1193 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using Contentstack.Management.Core.Exceptions;
+using Contentstack.Management.Core.Models;
+using Contentstack.Management.Core.Queryable;
+using Contentstack.Management.Core.Tests.Helpers;
+using Contentstack.Management.Core.Tests.Model;
+using Contentstack.Management.Core.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Contentstack.Management.Core.Tests.IntegrationTest
+{
+ ///
+ /// Workflow integration tests covering CRUD operations, publish rules, and error scenarios.
+ /// API requires 2–20 workflow stages per workflow; helpers enforce that minimum.
+ /// Tests are independent with unique naming to avoid conflicts. Cleanup is best-effort to maintain stack state.
+ ///
+ [TestClass]
+ [DoNotParallelize]
+ public class Contentstack020_WorkflowTest
+ {
+ private static ContentstackClient _client;
+ private Stack _stack;
+
+ // Test resource tracking for cleanup
+ private List _createdWorkflowUids = new List();
+ private List _createdPublishRuleUids = new List();
+ private string _testEnvironmentUid;
+
+ [ClassInitialize]
+ public static void ClassInitialize(TestContext context)
+ {
+ _client = Contentstack.CreateAuthenticatedClient();
+ }
+
+ [ClassCleanup]
+ public static void ClassCleanup()
+ {
+ try { _client?.Logout(); } catch { }
+ _client = null;
+ }
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ StackResponse response = StackResponse.getStack(_client.serializer);
+ _stack = _client.Stack(response.Stack.APIKey);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ // Best-effort cleanup of created resources
+ CleanupCreatedResources();
+ }
+
+ ///
+ /// Fails the test with a clear message from ContentstackErrorException or generic exception.
+ ///
+ private static void FailWithError(string operation, Exception ex)
+ {
+ if (ex is ContentstackErrorException cex)
+ AssertLogger.Fail($"{operation} failed. HTTP {(int)cex.StatusCode} ({cex.StatusCode}). ErrorCode: {cex.ErrorCode}. Message: {cex.ErrorMessage ?? cex.Message}");
+ else
+ AssertLogger.Fail($"{operation} failed: {ex.Message}");
+ }
+
+ ///
+ /// API may return 404 or 422 for missing or invalid workflow UIDs depending on endpoint.
+ ///
+ private static void AssertMissingWorkflowStatus(HttpStatusCode statusCode, string assertionName)
+ {
+ AssertLogger.IsTrue(
+ statusCode == HttpStatusCode.NotFound || statusCode == HttpStatusCode.UnprocessableEntity,
+ $"Expected 404 or 422 for missing workflow, got {(int)statusCode} ({statusCode})",
+ assertionName);
+ }
+
+ ///
+ /// Creates a test workflow model with specified name and stage count.
+ /// Contentstack API requires between 2 and 20 workflow stages.
+ ///
+ private WorkflowModel CreateTestWorkflowModel(string name, int stageCount = 2)
+ {
+ if (stageCount < 2 || stageCount > 20)
+ throw new ArgumentOutOfRangeException(nameof(stageCount), "API requires workflow_stages count between 2 and 20.");
+ var stages = GenerateTestStages(stageCount);
+ return new WorkflowModel
+ {
+ Name = name,
+ Enabled = true,
+ Branches = new List { "main" },
+ ContentTypes = new List { "$all" },
+ AdminUsers = new Dictionary { ["users"] = new List