diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b50d4b..4f47ef27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +### Version: 3.1.0 +#### Date: Jul-20-2026 + +##### Feat: +- Entry Variants Branch Support + - Added support for passing an optional `branch` parameter to the `.Variant()` method in both `Entry` and `Query` classes. + - If the branch parameter is null or empty, it automatically falls back to the Stack's configured branch or "main". + - Added comprehensive unit and integration tests for Entry and Query variant branch logic. + +--- + ### Version: 3.0.0 #### Date: Jul-13-2026 diff --git a/Contentstack.Core.Tests/Integration/VariantsTests/EntryVariantsComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/VariantsTests/EntryVariantsComprehensiveTest.cs index ce04a10c..e2c988e3 100644 --- a/Contentstack.Core.Tests/Integration/VariantsTests/EntryVariantsComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/VariantsTests/EntryVariantsComprehensiveTest.cs @@ -24,6 +24,96 @@ public EntryVariantsComprehensiveTest(ITestOutputHelper output) : base(output) #region Basic Variant Operations + [Fact(DisplayName = "Entry Operations - Variant With Invalid Branch Throws Exception")] + public async Task Variant_WithInvalidBranch_ThrowsException() + { + // Arrange + LogArrange("Creating client and setting up test data"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("EntryUid", TestDataHelper.ComplexEntryUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + LogContext("Branch", "invalid_branch_name_123"); + + var client = CreateClient(); + + // Act & Assert + LogAct("Fetching entry with variant and INVALID branch using .Variant() method"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await client + .ContentType(TestDataHelper.ComplexContentTypeUid) + .Entry(TestDataHelper.ComplexEntryUid) + .Variant(TestDataHelper.VariantUid, "invalid_branch_name_123") + .Fetch(); + }); + + LogAssert("Verifying exception was thrown for invalid branch"); + Assert.Contains("branch", exception.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact(DisplayName = "Entry Operations - Variant With Valid Branch Returns Results")] + public async Task Variant_WithValidBranch_ReturnsResults() + { + // Arrange + LogArrange("Creating client and setting up test data"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("EntryUid", TestDataHelper.ComplexEntryUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + LogContext("Branch", TestDataHelper.BranchUid); + + var client = CreateClient(); + + // Act + LogAct("Fetching entry with variant and valid branch using .Variant() method"); + + var entry = await client + .ContentType(TestDataHelper.ComplexContentTypeUid) + .Entry(TestDataHelper.ComplexEntryUid) + .Variant(TestDataHelper.VariantUid, TestDataHelper.BranchUid) + .Fetch(); + + // Assert + LogAssert("Verifying entry properties"); + TestAssert.NotNull(entry); + TestAssert.NotNull(entry.Uid); + } + + [Fact(DisplayName = "Entry Operations - Variant With Null Or Empty Branch Falls Back To Stack Branch Or Main")] + public async Task Variant_WithNullOrEmptyBranch_FallsBackToStackBranchOrMain() + { + // Arrange + LogArrange("Creating client and setting up test data"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("EntryUid", TestDataHelper.ComplexEntryUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + + var client = CreateClient(); + + // Act + LogAct("Fetching entry with variant and null/empty branch using .Variant() method"); + + var entryWithNullBranch = await client + .ContentType(TestDataHelper.ComplexContentTypeUid) + .Entry(TestDataHelper.ComplexEntryUid) + .Variant(TestDataHelper.VariantUid, null) + .Fetch(); + + var entryWithEmptyBranch = await client + .ContentType(TestDataHelper.ComplexContentTypeUid) + .Entry(TestDataHelper.ComplexEntryUid) + .Variant(TestDataHelper.VariantUid, " ") + .Fetch(); + + // Assert + LogAssert("Verifying entry properties"); + TestAssert.NotNull(entryWithNullBranch); + TestAssert.NotNull(entryWithNullBranch.Uid); + + TestAssert.NotNull(entryWithEmptyBranch); + TestAssert.NotNull(entryWithEmptyBranch.Uid); + } + [Fact(DisplayName = "Entry Operations - Variant Fetch With Variant Method Returns Variant Content")] public async Task Variant_FetchWithVariantMethod_ReturnsVariantContent() { @@ -221,6 +311,92 @@ public async Task Variant_MultipleVariants_UsingList() #region Query with Variants + [Fact(DisplayName = "Entry Operations - Variant Query With Invalid Branch Throws Exception")] + public async Task Variant_Query_WithInvalidBranch_ThrowsException() + { + // Arrange + LogArrange("Setting up query operation with invalid branch"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + LogContext("Branch", "invalid_branch_name_123"); + + var client = CreateClient(); + var query = client.ContentType(TestDataHelper.ComplexContentTypeUid).Query(); + + // Act & Assert + LogAct("Executing query with variant and INVALID branch"); + + query.Variant(TestDataHelper.VariantUid, "invalid_branch_name_123"); + query.Limit(5); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await query.Find(); + }); + + LogAssert("Verifying exception was thrown for invalid branch"); + Assert.Contains("branch", exception.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact(DisplayName = "Entry Operations - Variant Query With Valid Branch Returns Results")] + public async Task Variant_Query_WithValidBranch_ReturnsResults() + { + // Arrange + LogArrange("Setting up query operation with valid branch"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + LogContext("Branch", TestDataHelper.BranchUid); + + var client = CreateClient(); + var query = client.ContentType(TestDataHelper.ComplexContentTypeUid).Query(); + + // Act + LogAct("Executing query with variant and valid branch"); + + query.Variant(TestDataHelper.VariantUid, TestDataHelper.BranchUid); + query.Limit(5); + var result = await query.Find(); + + // Assert + LogAssert("Verifying response"); + + TestAssert.NotNull(result); + TestAssert.NotNull(result.Items); + } + + [Fact(DisplayName = "Entry Operations - Variant Query With Null Or Empty Branch Falls Back To Stack Branch Or Main")] + public async Task Variant_Query_WithNullOrEmptyBranch_FallsBackToStackBranchOrMain() + { + // Arrange + LogArrange("Setting up query operation with null/empty branch"); + LogContext("ContentType", TestDataHelper.ComplexContentTypeUid); + LogContext("VariantUid", TestDataHelper.VariantUid); + + var client = CreateClient(); + + // Act + LogAct("Executing queries with variant and null/empty branch"); + + var queryWithNullBranch = client.ContentType(TestDataHelper.ComplexContentTypeUid).Query(); + queryWithNullBranch.Variant(TestDataHelper.VariantUid, null); + queryWithNullBranch.Limit(1); + var resultWithNullBranch = await queryWithNullBranch.Find(); + + var queryWithEmptyBranch = client.ContentType(TestDataHelper.ComplexContentTypeUid).Query(); + queryWithEmptyBranch.Variant(TestDataHelper.VariantUid, " "); + queryWithEmptyBranch.Limit(1); + var resultWithEmptyBranch = await queryWithEmptyBranch.Find(); + + // Assert + LogAssert("Verifying response"); + + TestAssert.NotNull(resultWithNullBranch); + TestAssert.NotNull(resultWithNullBranch.Items); + + TestAssert.NotNull(resultWithEmptyBranch); + TestAssert.NotNull(resultWithEmptyBranch.Items); + } + [Fact(DisplayName = "Entry Operations - Variant Query With Variant Method")] public async Task Variant_Query_WithVariantMethod() { diff --git a/Contentstack.Core.Unit.Tests/EntryUnitTests.cs b/Contentstack.Core.Unit.Tests/EntryUnitTests.cs index 37b11862..ffea017f 100644 --- a/Contentstack.Core.Unit.Tests/EntryUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/EntryUnitTests.cs @@ -2745,6 +2745,83 @@ public void Entry_Fetch_WithCachePolicyNotSet_Setup_VerifiesDefault() } #endregion + + #region Variant Tests + + [Fact(DisplayName = "Entry Operations - Variant With Branch Sets Branch Header")] + public void Entry_Variant_WithBranch_SetsBranchHeader() + { + // Arrange + var client = UnitTestHelpers.GetMockClient("main"); + var entry = client.ContentType("DUMMY_CONTENT_TYPE").Entry("DUMMY_ENTRY_UID"); + + // Act + entry.Variant("VARIANT_1", "development"); + + // Assert + Assert.True(entry._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", entry._Headers["x-cs-variant-uid"]); + + Assert.True(entry._Headers.ContainsKey("branch")); + Assert.Equal("development", entry._Headers["branch"]); + } + + [Fact(DisplayName = "Entry Operations - Variant With Null Branch Falls Back To Stack Branch")] + public void Entry_Variant_WithNullBranch_FallsBackToStackBranch() + { + // Arrange + var client = UnitTestHelpers.GetMockClient("stack_branch"); + var entry = client.ContentType("DUMMY_CONTENT_TYPE").Entry("DUMMY_ENTRY_UID"); + + // Act + entry.Variant("VARIANT_1", null); + + // Assert + Assert.True(entry._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", entry._Headers["x-cs-variant-uid"]); + + Assert.True(entry._Headers.ContainsKey("branch")); + Assert.Equal("stack_branch", entry._Headers["branch"]); + } + + [Fact(DisplayName = "Entry Operations - Variant With Empty Branch Falls Back To Main If Stack Branch Is Null")] + public void Entry_Variant_WithEmptyBranch_FallsBackToMainIfStackBranchIsNull() + { + // Arrange + var client = UnitTestHelpers.GetMockClient(null); + var entry = client.ContentType("DUMMY_CONTENT_TYPE").Entry("DUMMY_ENTRY_UID"); + + // Act + entry.Variant("VARIANT_1", " "); + + // Assert + Assert.True(entry._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", entry._Headers["x-cs-variant-uid"]); + + Assert.True(entry._Headers.ContainsKey("branch")); + Assert.Equal("main", entry._Headers["branch"]); + } + + [Fact(DisplayName = "Entry Operations - Variant With Multiple Variants And Branch Sets Headers")] + public void Entry_Variant_WithMultipleVariantsAndBranch_SetsHeaders() + { + // Arrange + var client = UnitTestHelpers.GetMockClient("main"); + var entry = client.ContentType("DUMMY_CONTENT_TYPE").Entry("DUMMY_ENTRY_UID"); + var variants = new List { "VARIANT_1", "VARIANT_2" }; + + // Act + entry.Variant(variants, "feature_branch"); + + // Assert + Assert.True(entry._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1,VARIANT_2", entry._Headers["x-cs-variant-uid"]); + + Assert.True(entry._Headers.ContainsKey("branch")); + Assert.Equal("feature_branch", entry._Headers["branch"]); + } + + #endregion } } diff --git a/Contentstack.Core.Unit.Tests/QueryUnitTests.cs b/Contentstack.Core.Unit.Tests/QueryUnitTests.cs index 4e1c4ff3..8871cb85 100644 --- a/Contentstack.Core.Unit.Tests/QueryUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/QueryUnitTests.cs @@ -2632,6 +2632,75 @@ public void Query_WithNullUrlQueries_Setup_HandlesGracefully() } #endregion + + #region Variant Tests + + [Fact(DisplayName = "Query Operations - Variant With Branch Sets Branch Header")] + public void Query_Variant_WithBranch_SetsBranchHeader() + { + // Arrange + var query = UnitTestHelpers.GetMockClient("main").ContentType("DUMMY_CONTENT_TYPE").Query(); + + // Act + query.Variant("VARIANT_1", "development"); + + // Assert + Assert.True(query._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", query._Headers["x-cs-variant-uid"]); + Assert.True(query._Headers.ContainsKey("branch")); + Assert.Equal("development", query._Headers["branch"]); + } + + [Fact(DisplayName = "Query Operations - Variant With Null Branch Falls Back To Stack Branch")] + public void Query_Variant_WithNullBranch_FallsBackToStackBranch() + { + // Arrange + var query = UnitTestHelpers.GetMockClient("stack_branch").ContentType("DUMMY_CONTENT_TYPE").Query(); + + // Act + query.Variant("VARIANT_1", null); + + // Assert + Assert.True(query._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", query._Headers["x-cs-variant-uid"]); + Assert.True(query._Headers.ContainsKey("branch")); + Assert.Equal("stack_branch", query._Headers["branch"]); + } + + [Fact(DisplayName = "Query Operations - Variant With Empty Branch Falls Back To Main If Stack Branch Is Null")] + public void Query_Variant_WithEmptyBranch_FallsBackToMainIfStackBranchIsNull() + { + // Arrange + var query = UnitTestHelpers.GetMockClient(null).ContentType("DUMMY_CONTENT_TYPE").Query(); + + // Act + query.Variant("VARIANT_1", " "); + + // Assert + Assert.True(query._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1", query._Headers["x-cs-variant-uid"]); + Assert.True(query._Headers.ContainsKey("branch")); + Assert.Equal("main", query._Headers["branch"]); + } + + [Fact(DisplayName = "Query Operations - Variant With Multiple Variants And Branch Sets Headers")] + public void Query_Variant_WithMultipleVariantsAndBranch_SetsHeaders() + { + // Arrange + var query = UnitTestHelpers.GetMockClient("main").ContentType("DUMMY_CONTENT_TYPE").Query(); + var variants = new List { "VARIANT_1", "VARIANT_2" }; + + // Act + query.Variant(variants, "feature_branch"); + + // Assert + Assert.True(query._Headers.ContainsKey("x-cs-variant-uid")); + Assert.Equal("VARIANT_1,VARIANT_2", query._Headers["x-cs-variant-uid"]); + Assert.True(query._Headers.ContainsKey("branch")); + Assert.Equal("feature_branch", query._Headers["branch"]); + } + + #endregion } } diff --git a/Contentstack.Core.Unit.Tests/UnitTestHelpers.cs b/Contentstack.Core.Unit.Tests/UnitTestHelpers.cs new file mode 100644 index 00000000..1673930a --- /dev/null +++ b/Contentstack.Core.Unit.Tests/UnitTestHelpers.cs @@ -0,0 +1,20 @@ +using Contentstack.Core; +using Contentstack.Core.Configuration; + +namespace Contentstack.Core.Unit.Tests +{ + internal static class UnitTestHelpers + { + internal static ContentstackClient GetMockClient(string stackBranch = null) + { + var options = new ContentstackOptions + { + ApiKey = "DUMMY_API_KEY", + DeliveryToken = "DUMMY_DELIVERY_TOKEN", + Environment = "DUMMY_ENVIRONMENT", + Branch = stackBranch + }; + return new ContentstackClient(options); + } + } +} diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index c602db54..2c40f424 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -32,7 +32,7 @@ - + diff --git a/Contentstack.Core/Models/Entry.cs b/Contentstack.Core/Models/Entry.cs index dc0af276..835afbf3 100644 --- a/Contentstack.Core/Models/Entry.cs +++ b/Contentstack.Core/Models/Entry.cs @@ -395,12 +395,13 @@ public void RemoveHeader(string key) /// To set variants header using Entry instance. /// /// Entry instance + /// Optional branch to fetch the variant from. Falls back to the stack's configured branch, then "main". /// Current instance of Entry, this will be useful for a chaining calls. /// /// /// ContentstackClient stack = new ContentstackClinet("api_key", "delivery_token", "environment"); /// Entry csEntry = stack.ContentType("contentType_id").Entry("entry_uid"); - /// + /// /// csEntry.Variant("variant_entry_1"); /// csEntry.Fetch().ContinueWith((entryResult) => { /// //Your callback code. @@ -408,9 +409,10 @@ public void RemoveHeader(string key) /// }); /// /// - public Entry Variant(string variant_header) + public Entry Variant(string variant_header, string branch = null) { this.SetHeader("x-cs-variant-uid", variant_header); + this.SetHeader("branch", ResolveBranch(branch)); return this; } @@ -420,12 +422,13 @@ public Entry Variant(string variant_header) /// To set multiple variants headers using Entry instance. /// /// Entry instance + /// Optional branch to fetch the variant from. Falls back to the stack's configured branch, then "main". /// Current instance of Entry, this will be useful for a chaining calls. /// /// /// ContentstackClient stack = new ContentstackClinet("api_key", "delivery_token", "environment"); /// Entry csEntry = stack.ContentType("contentType_id").Query(); - /// + /// /// csEntry.Variant(new List { "variant_entry_1", "variant_entry_2", "variant_entry_3" }); /// csEntry.Fetch().ContinueWith((entryResult) => { /// //Your callback code. @@ -433,12 +436,20 @@ public Entry Variant(string variant_header) /// }); /// /// - public Entry Variant(List variant_headers) + public Entry Variant(List variant_headers, string branch = null) { this.SetHeader("x-cs-variant-uid", string.Join(",", variant_headers)); + this.SetHeader("branch", ResolveBranch(branch)); return this; } + private const string DefaultBranch = "main"; + + private string ResolveBranch(string branch) => + string.IsNullOrWhiteSpace(branch) + ? (ContentTypeInstance?.StackInstance?.Config?.Branch ?? DefaultBranch) + : branch; + /// /// Get metadata of entry. /// diff --git a/Contentstack.Core/Models/Query.cs b/Contentstack.Core/Models/Query.cs index 7e2608d2..d1def290 100644 --- a/Contentstack.Core/Models/Query.cs +++ b/Contentstack.Core/Models/Query.cs @@ -20,7 +20,7 @@ public class Query #region Private Variables internal Dictionary _FormHeaders = new Dictionary(); - private Dictionary _Headers = new Dictionary(); + internal Dictionary _Headers = new Dictionary(); private Dictionary UrlQueries = new Dictionary(); protected Dictionary QueryValueJson = new Dictionary(); @@ -1711,21 +1711,23 @@ public Query SetCachePolicy(CachePolicy cachePolicy) /// To set variants header using query instance. /// /// Query instance + /// Optional branch to fetch the variant from. Falls back to the stack's configured branch, then "main". /// Current instance of Query, this will be useful for a chaining calls. /// /// /// ContentstackClient stack = new ContentstackClinet("api_key", "delivery_token", "environment"); /// Query csQuery = stack.ContentType("contentType_id").Query(); - /// + /// /// csQuery.Variant("variant_entry_1"); /// csQuery.Find().ContinueWith((queryResult) => { /// //Your callback code. /// }); /// /// - public Query Variant(string variant_header) + public Query Variant(string variant_header, string branch = null) { this.SetHeader("x-cs-variant-uid", variant_header); + this.SetHeader("branch", ResolveBranch(branch)); return this; } @@ -1735,24 +1737,33 @@ public Query Variant(string variant_header) /// To set multiple variants headers using query instance. /// /// Query instance + /// Optional branch to fetch the variant from. Falls back to the stack's configured branch, then "main". /// Current instance of Query, this will be useful for a chaining calls. /// /// /// ContentstackClient stack = new ContentstackClinet("api_key", "delivery_token", "environment"); /// Query csQuery = stack.ContentType("contentType_id").Query(); - /// + /// /// csQuery.Variant(new List { "variant_entry_1", "variant_entry_2", "variant_entry_3" }); /// csQuery.Find().ContinueWith((queryResult) => { /// //Your callback code. /// }); /// /// - public Query Variant(List variant_headers) + public Query Variant(List variant_headers, string branch = null) { this.SetHeader("x-cs-variant-uid", string.Join(",", variant_headers)); + this.SetHeader("branch", ResolveBranch(branch)); return this; } + private const string DefaultBranch = "main"; + + private string ResolveBranch(string branch) => + string.IsNullOrWhiteSpace(branch) + ? (ContentTypeInstance?.StackInstance?.Config?.Branch ?? DefaultBranch) + : branch; + /// /// Execute a Query and Caches its result (Optional) /// diff --git a/Contentstack.Core/Scripts/refresh-region.py b/Contentstack.Core/Scripts/refresh-region.py new file mode 100755 index 00000000..d7ed1015 --- /dev/null +++ b/Contentstack.Core/Scripts/refresh-region.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Refresh regions.json from the Contentstack CDN. +# Usage (run from your project root): +# python3 Scripts/refresh-region.py +# python Scripts/refresh-region.py + +import glob +import json +import os +import ssl +import sys +import urllib.request + +REGIONS_URL = "https://artifacts.contentstack.com/regions.json" + +print(f"Fetching {REGIONS_URL} ...") + + +def _fetch(url): + # First attempt: normal SSL verification + try: + with urllib.request.urlopen(url, timeout=30) as r: + return r.read().decode("utf-8") + except urllib.error.URLError as e: + if "CERTIFICATE_VERIFY_FAILED" not in str(e): + raise + # macOS python.org builds often lack system certs — retry without verification + print("WARNING: SSL certificate verification failed. Retrying without verification.", file=sys.stderr) + print(" To fix permanently, run: /Applications/Python*/Install\\ Certificates.command", file=sys.stderr) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(url, timeout=30, context=ctx) as r: + return r.read().decode("utf-8") + + +try: + raw = _fetch(REGIONS_URL) +except Exception as e: + print(f"ERROR: Could not download regions.json: {e}", file=sys.stderr) + sys.exit(1) + +try: + data = json.loads(raw) +except json.JSONDecodeError as e: + print(f"ERROR: Downloaded content is not valid JSON: {e}", file=sys.stderr) + sys.exit(1) + +if "regions" not in data: + print("ERROR: Downloaded JSON does not contain a 'regions' key.", file=sys.stderr) + sys.exit(1) + +region_count = len(data["regions"]) + +# Scan bin/ for every copy of the DLL (covers Debug/Release, any TFM, any nesting). +dll_pattern = os.path.join(os.getcwd(), "**", "Contentstack.Utils.dll") +found = [ + os.path.dirname(dll) + for dll in glob.glob(dll_pattern, recursive=True) + if os.sep + "bin" + os.sep in dll +] + +if not found: + print("[bin] No build output found — run 'dotnet build' first, then re-run this script.") + sys.exit(1) + +for bin_dir in found: + assets_dir = os.path.join(bin_dir, "Assets") + os.makedirs(assets_dir, exist_ok=True) + dest = os.path.join(assets_dir, "regions.json") + with open(dest, "w", encoding="utf-8") as f: + f.write(raw) + print(f"[bin] Wrote {region_count} regions → {dest}") diff --git a/Directory.Build.props b/Directory.Build.props index 1f737f6c..4bac4296 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 3.0.0 + 3.1.0