From aafef9ce415abbace5e2b053aecae522e716099a Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 27 Jul 2026 18:37:02 +0530 Subject: [PATCH 1/4] feat: add Taxonomy list-all endpoint and Term/TermQuery hierarchy support Brings v2-legacy's CDA Taxonomy/Term/TermQuery classes to parity with the newer Taxonomy Publishing feature set: adds Taxonomies().List() for GET /taxonomies, Depth/IncludeBranch on Term and TermQuery, and Skip/Limit/IncludeCount pagination on TermQuery. Consolidates the previously-triplicated request-building/header-merge/error-parsing logic across Taxonomy/Term/TermQuery into a single Internals/TaxonomyRequestHelper, fixing a bug where GetHeader's local-header merge was never actually invoked. Also fixes Term.Locales/Ancestors/Descendants, which were reading the wrong JSON envelope key ($.locales/$.ancestors/$.descendants) instead of the CDA's actual $.terms wrapper - confirmed against the live API, where it silently returned the raw envelope instead of throwing for loosely-typed callers. Co-Authored-By: Claude Sonnet 5 --- .../Taxonomy/TaxonomyLocalisationTest.cs | 229 +++++++++++++++- .../TaxonomyUnitTests.cs | 253 ++++++++++++++---- .../Internals/TaxonomyRequestHelper.cs | 156 +++++++++++ Contentstack.Core/Models/Taxonomy.cs | 192 ++++--------- Contentstack.Core/Models/Term.cs | 76 +++--- Contentstack.Core/Models/TermQuery.cs | 102 +++++-- 6 files changed, 758 insertions(+), 250 deletions(-) create mode 100644 Contentstack.Core/Internals/TaxonomyRequestHelper.cs diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index 223f936f..a4132a62 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -5,8 +5,6 @@ using Xunit; using Xunit.Abstractions; using Contentstack.Core.Configuration; -using Contentstack.Core.Models; -using Contentstack.Core.Internals; using Contentstack.Core.Tests.Helpers; namespace Contentstack.Core.Tests.Integration.Taxonomy @@ -276,5 +274,232 @@ public async Task Term_Descendants_ReturnsDescendantsCollection() LogAssert("Verifying response"); Assert.NotNull(result); } + + // ── 8. Term hierarchy — Depth / IncludeBranch ──────────────────────── + + /// + /// Walks the gadgets taxonomy to find a term with at least one descendant that itself + /// has a descendant — i.e. a parent/child/grandchild chain — for hierarchy-depth tests. + /// Returns (null, null, null) if no such chain exists in the fixture data. + /// + private async Task<(string parentUid, string childUid, string grandchildUid)> GetTermHierarchyAsync(ContentstackClient client) + { + var terms = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .Find(); + + foreach (var candidateParent in terms.Items) + { + var parentUid = candidateParent["uid"]?.ToString(); + if (string.IsNullOrEmpty(parentUid)) continue; + + var children = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(parentUid) + .Descendants(); + var childUid = children?.FirstOrDefault()?["uid"]?.ToString(); + if (string.IsNullOrEmpty(childUid)) continue; + + var grandchildren = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(childUid) + .Descendants(); + var grandchildUid = grandchildren?.FirstOrDefault()?["uid"]?.ToString(); + if (string.IsNullOrEmpty(grandchildUid)) continue; + + return (parentUid, childUid, grandchildUid); + } + return (null, null, null); + } + + [Fact(DisplayName = "TaxPublish - Term.Descendants with depth=1 returns only direct children")] + public async Task Term_Descendants_WithDepth1_ReturnsOnlyDirectChildren() + { + var client = CreateGadgetsClient(); + var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client); + + if (string.IsNullOrEmpty(parentUid)) + { + Output.WriteLine("No parent/child/grandchild term chain found — skipping test."); + return; + } + + LogArrange("Fetching direct-child-only descendants (depth=1)"); + LogContext("ParentTermUid", parentUid); + + LogAct("Calling Term(parentUid).Depth(1).Descendants()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(parentUid) + .Depth(1) + .Descendants(); + + LogAssert("Verifying only the direct child is present, not the grandchild"); + Assert.NotNull(result); + var uids = result.Select(t => t["uid"]?.ToString()).ToList(); + Assert.Contains(childUid, uids); + Assert.DoesNotContain(grandchildUid, uids); + } + + [Fact(DisplayName = "TaxPublish - Term.Descendants with depth=2 includes grandchildren")] + public async Task Term_Descendants_WithDepth2_IncludesGrandchildren() + { + var client = CreateGadgetsClient(); + var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client); + + if (string.IsNullOrEmpty(parentUid)) + { + Output.WriteLine("No parent/child/grandchild term chain found — skipping test."); + return; + } + + LogArrange("Fetching descendants two levels deep (depth=2)"); + LogContext("ParentTermUid", parentUid); + + LogAct("Calling Term(parentUid).Depth(2).Descendants()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(parentUid) + .Depth(2) + .Descendants(); + + LogAssert("Verifying both the child and grandchild are present"); + Assert.NotNull(result); + var uids = result.Select(t => t["uid"]?.ToString()).ToList(); + Assert.Contains(childUid, uids); + Assert.Contains(grandchildUid, uids); + } + + [Fact(DisplayName = "TaxPublish - Term.Ancestors for a grandchild term returns the full parent chain")] + public async Task Term_Ancestors_ForGrandchildTerm_ReturnsFullChain() + { + var client = CreateGadgetsClient(); + var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client); + + if (string.IsNullOrEmpty(grandchildUid)) + { + Output.WriteLine("No parent/child/grandchild term chain found — skipping test."); + return; + } + + LogArrange("Fetching ancestors for the grandchild term"); + LogContext("GrandchildTermUid", grandchildUid); + + LogAct("Calling Term(grandchildUid).Ancestors()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(grandchildUid) + .Ancestors(); + + LogAssert("Verifying both the parent and child (grandparent chain) are present"); + Assert.NotNull(result); + var uids = result.Select(t => t["uid"]?.ToString()).ToList(); + Assert.Contains(childUid, uids); + Assert.Contains(parentUid, uids); + } + + [Fact(DisplayName = "TaxPublish - TermQuery.Find with depth limits the returned hierarchy")] + public async Task TermQuery_Find_WithDepth_LimitsHierarchyDepth() + { + var client = CreateGadgetsClient(); + + LogArrange("Finding terms with depth=1"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + + LogAct("Calling Terms().Depth(1).Find()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .Depth(1) + .Find(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + } + + [Fact(DisplayName = "TaxPublish - Term.Fetch with IncludeBranch returns branch info")] + public async Task Term_Fetch_WithIncludeBranch_ReturnsBranchInfo() + { + var client = CreateGadgetsClient(); + var termUid = await GetFirstTermUidAsync(client); + + if (string.IsNullOrEmpty(termUid)) + { + Output.WriteLine("No term UID found — skipping test."); + return; + } + + LogArrange("Fetching a term with branch info included"); + LogContext("TermUid", termUid); + + LogAct("Calling Term(termUid).IncludeBranch().Fetch()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .IncludeBranch() + .Fetch(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result["uid"]?.ToString()); + } + + // ── 9. List all taxonomies ──────────────────────────────────────────── + + [Fact(DisplayName = "TaxPublish - List all taxonomies returns a collection")] + public async Task List_AllTaxonomies_ReturnsCollection() + { + var client = CreateGadgetsClient(); + + LogArrange("Listing all published taxonomies"); + + LogAct("Calling Taxonomies().List()"); + var result = await client + .Taxonomies() + .List(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + Assert.True(result.Items.Any()); + } + + [Fact(DisplayName = "TaxPublish - List all taxonomies with skip/limit returns a paged subset")] + public async Task List_AllTaxonomies_WithSkipAndLimit_ReturnsPagedSubset() + { + var client = CreateGadgetsClient(); + + LogArrange("Listing taxonomies with skip/limit"); + + LogAct("Calling Taxonomies().AddParam(\"skip\",\"0\").AddParam(\"limit\",\"1\").List()"); + var result = await client + .Taxonomies() + .AddParam("skip", "0") + .AddParam("limit", "1") + .List(); + + LogAssert("Verifying response is limited to at most one item"); + Assert.NotNull(result); + Assert.True(result.Items.Count() <= 1); + } + + [Fact(DisplayName = "TaxPublish - List all taxonomies with include_count returns a count")] + public async Task List_AllTaxonomies_WithIncludeCount_ReturnsCount() + { + var client = CreateGadgetsClient(); + + LogArrange("Listing taxonomies with include_count"); + + LogAct("Calling Taxonomies().AddParam(\"include_count\",\"true\").List()"); + var result = await client + .Taxonomies() + .AddParam("include_count", "true") + .List(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + } } } diff --git a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs index a7c49388..2ee812f1 100644 --- a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Threading.Tasks; using AutoFixture; using Contentstack.Core; using Contentstack.Core.Configuration; @@ -284,62 +285,55 @@ public void Below_WithDoubleValue_AddsQueryParameter() } [Fact] - public void Taxonomy_GetHeader_WithLocalHeaders_ReturnsMergedHeaders() + public void TaxonomyRequestHelper_GetHeader_WithLocalHeaders_ReturnsMergedHeaders() { // Arrange - var taxonomy = CreateTaxonomy(); - var type = typeof(Taxonomy); - var getHeaderMethod = type.GetMethod("GetHeader", BindingFlags.NonPublic | BindingFlags.Instance); - + var stackHeaders = new Dictionary { { "stack-header", "stack-value" } }; var localHeaders = new Dictionary { { "custom-header", "value1" } }; - + // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { localHeaders }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(stackHeaders, localHeaders); // Assert Assert.NotNull(result); } [Fact] - public void Taxonomy_GetHeader_WithNullLocalHeaders_ReturnsStackHeaders() + public void TaxonomyRequestHelper_GetHeader_WithNullLocalHeaders_ReturnsStackHeaders() { // Arrange - var taxonomy = CreateTaxonomy(); - var type = typeof(Taxonomy); - var getHeaderMethod = type.GetMethod("GetHeader", BindingFlags.NonPublic | BindingFlags.Instance); - + var stackHeaders = new Dictionary { { "stack-header", "stack-value" } }; + // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { null }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(stackHeaders, null); // Assert Assert.NotNull(result); + Assert.Same(stackHeaders, result); } [Fact] - public void Taxonomy_GetHeader_WithEmptyLocalHeaders_ReturnsStackHeaders() + public void TaxonomyRequestHelper_GetHeader_WithEmptyLocalHeaders_ReturnsStackHeaders() { // Arrange - var taxonomy = CreateTaxonomy(); - var type = typeof(Taxonomy); - var getHeaderMethod = type.GetMethod("GetHeader", BindingFlags.NonPublic | BindingFlags.Instance); - + var stackHeaders = new Dictionary { { "stack-header", "stack-value" } }; + // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { new Dictionary() }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(stackHeaders, new Dictionary()); // Assert Assert.NotNull(result); + Assert.Same(stackHeaders, result); } [Fact] - public void Taxonomy_GetContentstackError_WithWebException_ReturnsContentstackException() + public void TaxonomyRequestHelper_GetContentstackError_WithWebException_ReturnsContentstackException() { // Arrange - var type = typeof(Taxonomy); - var getContentstackErrorMethod = type.GetMethod("GetContentstackError", BindingFlags.NonPublic | BindingFlags.Static); var webEx = new System.Net.WebException("Test exception"); // Act - var result = getContentstackErrorMethod?.Invoke(null, new object[] { webEx }) as ContentstackException; + var result = TaxonomyRequestHelper.GetContentstackError(webEx); // Assert Assert.NotNull(result); @@ -347,15 +341,13 @@ public void Taxonomy_GetContentstackError_WithWebException_ReturnsContentstackEx } [Fact] - public void Taxonomy_GetContentstackError_WithGenericException_ReturnsContentstackException() + public void TaxonomyRequestHelper_GetContentstackError_WithGenericException_ReturnsContentstackException() { // Arrange - var type = typeof(Taxonomy); - var getContentstackErrorMethod = type.GetMethod("GetContentstackError", BindingFlags.NonPublic | BindingFlags.Static); var ex = new Exception("Test exception"); // Act - var result = getContentstackErrorMethod?.Invoke(null, new object[] { ex }) as ContentstackException; + var result = TaxonomyRequestHelper.GetContentstackError(ex); // Assert Assert.NotNull(result); @@ -397,21 +389,13 @@ public void Taxonomy_Find_Setup_VerifiesQueryParameters() } [Fact] - public void Taxonomy_GetHeader_WithLocalHeaderAndEmptyStackHeaders_ReturnsLocalHeader() + public void TaxonomyRequestHelper_GetHeader_WithLocalHeaderAndEmptyStackHeaders_ReturnsLocalHeader() { // Arrange - var taxonomy = CreateTaxonomy(); - var getHeaderMethod = typeof(Taxonomy).GetMethod("GetHeader", - BindingFlags.NonPublic | BindingFlags.Instance); var localHeader = new Dictionary { { "custom", "value" } }; - - // Set _StackHeaders to empty dictionary - var stackHeadersField = typeof(Taxonomy).GetField("_StackHeaders", - BindingFlags.NonPublic | BindingFlags.Instance); - stackHeadersField?.SetValue(taxonomy, new Dictionary()); // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { localHeader }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(new Dictionary(), localHeader); // Assert Assert.NotNull(result); @@ -419,16 +403,14 @@ public void Taxonomy_GetHeader_WithLocalHeaderAndEmptyStackHeaders_ReturnsLocalH } [Fact] - public void Taxonomy_GetHeader_WithOverlappingKeys_LocalHeaderTakesPrecedence() + public void TaxonomyRequestHelper_GetHeader_WithOverlappingKeys_LocalHeaderTakesPrecedence() { // Arrange - var taxonomy = CreateTaxonomy(); - var getHeaderMethod = typeof(Taxonomy).GetMethod("GetHeader", - BindingFlags.NonPublic | BindingFlags.Instance); + var stackHeaders = new Dictionary { { "custom", "stack_value" } }; var localHeader = new Dictionary { { "custom", "local_value" } }; // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { localHeader }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(stackHeaders, localHeader); // Assert Assert.NotNull(result); @@ -436,32 +418,29 @@ public void Taxonomy_GetHeader_WithOverlappingKeys_LocalHeaderTakesPrecedence() } [Fact] - public void Taxonomy_GetHeader_WithBothHeaders_ReturnsMergedHeaders() + public void TaxonomyRequestHelper_GetHeader_WithBothHeaders_ReturnsMergedHeaders() { // Arrange - var taxonomy = CreateTaxonomy(); - var getHeaderMethod = typeof(Taxonomy).GetMethod("GetHeader", - BindingFlags.NonPublic | BindingFlags.Instance); + var stackHeaders = new Dictionary { { "stack_key", "stack_value" } }; var localHeader = new Dictionary { { "local_key", "local_value" } }; // Act - var result = getHeaderMethod?.Invoke(taxonomy, new object[] { localHeader }) as Dictionary; + var result = TaxonomyRequestHelper.GetHeader(stackHeaders, localHeader); // Assert Assert.NotNull(result); Assert.True(result.ContainsKey("local_key")); + Assert.True(result.ContainsKey("stack_key")); } [Fact] - public void Taxonomy_GetContentstackError_WithWebExceptionContainingErrorCode_ExtractsErrorCode() + public void TaxonomyRequestHelper_GetContentstackError_WithWebExceptionContainingErrorCode_ExtractsErrorCode() { // Arrange - var type = typeof(Taxonomy); - var getContentstackErrorMethod = type.GetMethod("GetContentstackError", BindingFlags.NonPublic | BindingFlags.Static); var webEx = new System.Net.WebException("Test exception"); // Act - var result = getContentstackErrorMethod?.Invoke(null, new object[] { webEx }) as ContentstackException; + var result = TaxonomyRequestHelper.GetContentstackError(webEx); // Assert Assert.NotNull(result); @@ -717,6 +696,87 @@ public void TermQuery_AddParam_SetsUrlQuery() #endregion + #region TermQuery.Skip / Limit / IncludeCount Tests + + [Fact] + public void TermQuery_IncludeBranch_SetsQueryParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.IncludeBranch(); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("include_branch") ?? false); + Assert.Equal("true", queryParams["include_branch"]); + } + + [Fact] + public void TermQuery_Skip_SetsQueryParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.Skip(10); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("skip") ?? false); + Assert.Equal(10, queryParams["skip"]); + } + + [Fact] + public void TermQuery_Limit_SetsQueryParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.Limit(10); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("limit") ?? false); + Assert.Equal(10, queryParams["limit"]); + } + + [Fact] + public void TermQuery_IncludeCount_SetsQueryParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.IncludeCount(); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("include_count") ?? false); + Assert.Equal("true", queryParams["include_count"]); + } + + [Fact] + public void TermQuery_Skip_Limit_IncludeCount_Depth_ChainAllTogether() + { + var termQuery = _client.Taxonomies("gadgets").Terms() + .Skip(0) + .Limit(10) + .IncludeCount() + .Depth(2) + .IncludeBranch(); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("skip") ?? false); + Assert.True(queryParams?.ContainsKey("limit") ?? false); + Assert.True(queryParams?.ContainsKey("include_count") ?? false); + Assert.True(queryParams?.ContainsKey("depth") ?? false); + Assert.True(queryParams?.ContainsKey("include_branch") ?? false); + } + + #endregion + #region Taxonomy.SetLocale / IncludeFallback / AddParam Tests [Fact] @@ -807,6 +867,34 @@ public void Taxonomy_SetLocale_Then_IncludeFallback_ChainsBoth() #endregion + #region Taxonomy.List Tests + + [Fact] + public async Task Taxonomy_List_OnScopedInstance_ThrowsTaxonomyException() + { + var taxonomy = _client.Taxonomies("gadgets"); + await Assert.ThrowsAsync(() => taxonomy.List()); + } + + [Fact] + public void Taxonomy_List_AddParam_SetsSkipLimitIncludeCountParams() + { + var taxonomy = _client.Taxonomies() + .AddParam("skip", "0") + .AddParam("limit", "10") + .AddParam("include_count", "true"); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.True(urlQueries?.ContainsKey("skip") ?? false); + Assert.True(urlQueries?.ContainsKey("limit") ?? false); + Assert.True(urlQueries?.ContainsKey("include_count") ?? false); + } + + #endregion + #region Term.SetLocale / IncludeFallback / AddParam Tests [Fact] @@ -897,6 +985,69 @@ public void Term_SetLocale_Then_IncludeFallback_ChainsBoth() #endregion + #region Term.Depth / IncludeBranch Tests + + [Fact] + public void Term_Depth_ReturnsSelfForChaining() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + var result = term.Depth(2); + Assert.Same(term, result); + } + + [Fact] + public void Term_Depth_SetsQueryParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.Depth(2); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("depth") ?? false); + Assert.Equal(2, queryParams["depth"]); + } + + [Fact] + public void Term_IncludeBranch_ReturnsSelfForChaining() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + var result = term.IncludeBranch(); + Assert.Same(term, result); + } + + [Fact] + public void Term_IncludeBranch_SetsQueryParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.IncludeBranch(); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("include_branch") ?? false); + Assert.Equal("true", queryParams["include_branch"]); + } + + [Fact] + public void Term_Depth_Then_IncludeBranch_ChainsBoth() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch") + .Depth(2) + .IncludeBranch(); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("depth") ?? false); + Assert.True(queryParams?.ContainsKey("include_branch") ?? false); + } + + #endregion + #region Term Constructor Validation Tests [Fact] diff --git a/Contentstack.Core/Internals/TaxonomyRequestHelper.cs b/Contentstack.Core/Internals/TaxonomyRequestHelper.cs new file mode 100644 index 00000000..3be15144 --- /dev/null +++ b/Contentstack.Core/Internals/TaxonomyRequestHelper.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Core.Internals +{ + /// + /// Shared request-building, header-merging, and error-handling logic for the CDA + /// Taxonomy/Term/TermQuery classes. Every taxonomy-related HTTP call goes through + /// so header merging and error parsing behave identically + /// across Taxonomy, Term, and TermQuery. + /// + internal static class TaxonomyRequestHelper + { + /// + /// Merges a call-local header set with the stack's headers, local values taking precedence. + /// Passing null or empty for returns the stack headers unchanged. + /// + internal static Dictionary GetHeader(Dictionary stackHeaders, Dictionary localHeader) + { + if (localHeader != null && localHeader.Count > 0) + { + if (stackHeaders != null && stackHeaders.Count > 0) + { + Dictionary classHeaders = new Dictionary(); + foreach (var entry in localHeader) + classHeaders.Add(entry.Key, entry.Value); + + foreach (var entry in stackHeaders) + if (!classHeaders.ContainsKey(entry.Key)) + classHeaders.Add(entry.Key, entry.Value); + + return classHeaders; + } + return localHeader; + } + return stackHeaders; + } + + /// + /// Builds and sends a taxonomy/term GET request against the CDA and returns the raw response body. + /// + /// The owning client, providing config, headers, and the HTTP handler. + /// The fully-qualified request URL. + /// Query parameters accumulated by the calling class's modifiers (locale, depth, skip, ...). + /// Optional call-local headers, merged with precedence over the stack's headers. + internal static async Task ExecuteRequest( + ContentstackClient stack, + string url, + Dictionary urlQueries, + Dictionary localHeader = null) + { + var headerAll = GetHeader(stack._LocalHeaders, localHeader); + + var mainJson = new Dictionary(); + if (stack.Config?.Environment != null) + mainJson["environment"] = stack.Config.Environment; + + if (urlQueries != null) + foreach (var kvp in urlQueries) + mainJson[kvp.Key] = kvp.Value; + + var handler = new HttpRequestHandler(stack); + return await handler.ProcessRequest( + url, headerAll, mainJson, + Branch: stack.Config.Branch, + timeout: stack.Config.Timeout, + proxy: stack.Config.Proxy + ); + } + + /// + /// Parses a failed taxonomy/term request into a , + /// extracting error_code/error_message/errors from the response body when present. + /// + internal static ContentstackException GetContentstackError(Exception ex) + { + Int32 errorCode = 0; + string errorMessage; + HttpStatusCode statusCode = HttpStatusCode.InternalServerError; + Dictionary errors = null; + + try + { + System.Net.WebException webEx = ex as System.Net.WebException; + + if (webEx != null && webEx.Response != null) + { + using (var exResp = webEx.Response) + { + var stream = exResp.GetResponseStream(); + if (stream != null) + { + using (stream) + using (var reader = new StreamReader(stream)) + { + errorMessage = reader.ReadToEnd(); + + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + try + { + JObject data = JObject.Parse(errorMessage.Replace("\r\n", "")); + + JToken token = data["error_code"]; + if (token != null) + errorCode = token.Value(); + + token = data["error_message"]; + if (token != null) + errorMessage = token.Value(); + + token = data["errors"]; + if (token != null) + errors = token.ToObject>(); + } + catch (Newtonsoft.Json.JsonException) + { + // If JSON parsing fails, use the raw error message + // errorMessage is already set from ReadToEnd() + } + } + + var response = exResp as HttpWebResponse; + if (response != null) + statusCode = response.StatusCode; + } + } + else + { + errorMessage = webEx.Message; + } + } + } + else + { + errorMessage = ex.Message; + } + } + catch + { + errorMessage = ex.Message; + } + + return new ContentstackException(errorMessage) + { + ErrorCode = errorCode, + StatusCode = statusCode, + Errors = errors + }; + } + } +} diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index 6d04455e..20cf056e 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Net; +using System.Linq; using System.Threading.Tasks; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; -using Newtonsoft.Json.Linq; namespace Contentstack.Core.Models { @@ -19,9 +17,6 @@ public class Taxonomy: Query { #region Internal Variables - private Dictionary _ObjectAttributes = new Dictionary(); - private Dictionary _Headers = new Dictionary(); - private Dictionary _StackHeaders = new Dictionary(); private Dictionary UrlQueries = new Dictionary(); private string _uid = null; @@ -62,7 +57,6 @@ internal Taxonomy(ContentstackClient stack): base(stack) throw new TaxonomyException("ContentstackClient instance cannot be null when creating a Taxonomy instance."); } this.Stack = stack; - this._StackHeaders = stack._LocalHeaders; } internal Taxonomy(ContentstackClient stack, string uid) : this(stack) @@ -182,23 +176,7 @@ public async Task Fetch() try { - var headerAll = new Dictionary(); - foreach (var header in Stack._LocalHeaders) - headerAll[header.Key] = header.Value; - - var mainJson = new Dictionary(); - if (Stack.Config?.Environment != null) - mainJson["environment"] = Stack.Config.Environment; - foreach (var kvp in UrlQueries) - mainJson[kvp.Key] = kvp.Value; - - var handler = new HttpRequestHandler(Stack); - var result = await handler.ProcessRequest( - _Url, headerAll, mainJson, - Branch: Stack.Config.Branch, - timeout: Stack.Config.Timeout, - proxy: Stack.Config.Proxy - ); + var result = await TaxonomyRequestHelper.ExecuteRequest(Stack, _Url, UrlQueries); var jObject = Newtonsoft.Json.Linq.JObject.Parse(result); var token = jObject.SelectToken("$.taxonomy"); @@ -208,7 +186,51 @@ public async Task Fetch() } catch (Exception ex) { - var contentstackError = GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + + /// + /// Fetches all published taxonomies from the CDA. + /// Only valid on an unscoped instance (client.Taxonomies(), no UID) — + /// throws if called on a UID-scoped instance. + /// Use -style pagination via , or the + /// dedicated skip/limit/include_count params, before calling. + /// + /// A containing the published taxonomies. + /// + /// + /// ContentstackClient stack = new ContentstackClient("api_key", "delivery_token", "environment"); + /// var result = await stack.Taxonomies().List<MyTaxonomy>(); + /// var page = await stack.Taxonomies().AddParam("skip", "0").AddParam("limit", "10").List<MyTaxonomy>(); + /// + /// + public async Task> List() + { + if (_uid != null) + throw new TaxonomyException("List() is only valid on an unscoped Taxonomy. Use client.Taxonomies() without a UID."); + + try + { + Config config = this.Stack.Config; + var url = String.Format("{0}/taxonomies", config.BaseUrl); + var result = await TaxonomyRequestHelper.ExecuteRequest(Stack, url, UrlQueries); + + var jObject = Newtonsoft.Json.Linq.JObject.Parse(result); + var taxonomies = jObject.SelectToken("$.taxonomies")?.ToObject>(Stack.Serializer); + var collection = jObject.ToObject>(Stack.Serializer); + collection.Items = taxonomies ?? Enumerable.Empty(); + return collection; + } + catch (Exception ex) + { + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, @@ -373,125 +395,5 @@ public Taxonomy EqualAndBelow(String key, Object value) } #endregion - #region Private Functions - - private Dictionary GetHeader(Dictionary localHeader) - { - Dictionary mainHeader = _StackHeaders; - Dictionary classHeaders = new Dictionary(); - - if (localHeader != null && localHeader.Count > 0) - { - if (mainHeader != null && mainHeader.Count > 0) - { - foreach (var entry in localHeader) - { - String key = entry.Key; - classHeaders.Add(key, entry.Value); - } - - foreach (var entry in mainHeader) - { - String key = entry.Key; - if (!classHeaders.ContainsKey(key)) - { - classHeaders.Add(key, entry.Value); - } - } - - return classHeaders; - - } - else - { - return localHeader; - } - - } - else - { - return _StackHeaders; - } - } - internal new static ContentstackException GetContentstackError(Exception ex) - { - Int32 errorCode = 0; - string errorMessage = string.Empty; - HttpStatusCode statusCode = HttpStatusCode.InternalServerError; - ContentstackException contentstackError = new ContentstackException(ex); - Dictionary errors = null; - - try - { - System.Net.WebException webEx = ex as System.Net.WebException; - - if (webEx != null && webEx.Response != null) - { - using (var exResp = webEx.Response) - { - var stream = exResp.GetResponseStream(); - if (stream != null) - { - using (stream) - using (var reader = new StreamReader(stream)) - { - errorMessage = reader.ReadToEnd(); - - if (!string.IsNullOrWhiteSpace(errorMessage)) - { - try - { - JObject data = JObject.Parse(errorMessage.Replace("\r\n", "")); - - JToken token = data["error_code"]; - if (token != null) - errorCode = token.Value(); - - token = data["error_message"]; - if (token != null) - errorMessage = token.Value(); - - token = data["errors"]; - if (token != null) - errors = token.ToObject>(); - } - catch (Newtonsoft.Json.JsonException) - { - // If JSON parsing fails, use the raw error message - // errorMessage is already set from ReadToEnd() - } - } - - var response = exResp as HttpWebResponse; - if (response != null) - statusCode = response.StatusCode; - } - } - else - { - errorMessage = webEx.Message; - } - } - } - else - { - errorMessage = ex.Message; - } - } - catch - { - errorMessage = ex.Message; - } - - contentstackError = new ContentstackException(errorMessage) - { - ErrorCode = errorCode, - StatusCode = statusCode, - Errors = errors - }; - - return contentstackError; - } - #endregion } } diff --git a/Contentstack.Core/Models/Term.cs b/Contentstack.Core/Models/Term.cs index 7aeceb05..c3fa6103 100644 --- a/Contentstack.Core/Models/Term.cs +++ b/Contentstack.Core/Models/Term.cs @@ -82,6 +82,38 @@ public Term AddParam(string key, string value) return this; } + /// + /// Limits the depth of the term hierarchy returned by /. + /// Depth(1) returns only immediate parents/children; higher values include deeper levels. + /// + /// The maximum number of hierarchy levels to include. + /// The current for chaining. + /// + /// + /// var children = await stack.Taxonomies("gadgets").Term("laptops").Depth(1).Descendants<JToken>(); + /// + /// + public Term Depth(int depth) + { + UrlQueries["depth"] = depth; + return this; + } + + /// + /// Includes branch information in the response for this term. + /// + /// The current for chaining. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").IncludeBranch().Fetch<MyTerm>(); + /// + /// + public Term IncludeBranch() + { + UrlQueries["include_branch"] = "true"; + return this; + } + /// /// Fetches the published term from the CDA. /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid} with any configured query parameters. @@ -98,7 +130,7 @@ public async Task Fetch() { try { - var result = await ExecuteRequest(BaseUrlPath, UrlQueries); + var result = await TaxonomyRequestHelper.ExecuteRequest(_stack, BaseUrlPath, UrlQueries); var jObject = JObject.Parse(result); var token = jObject.SelectToken("$.term"); if (token != null) @@ -111,7 +143,7 @@ public async Task Fetch() } catch (Exception ex) { - var contentstackError = Taxonomy.GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, @@ -135,9 +167,9 @@ public async Task Locales() { try { - var result = await ExecuteRequest($"{BaseUrlPath}/locales"); + var result = await TaxonomyRequestHelper.ExecuteRequest(_stack, $"{BaseUrlPath}/locales", null); var jObject = JObject.Parse(result); - var token = jObject.SelectToken("$.locales"); + var token = jObject.SelectToken("$.terms"); if (token != null) return token.ToObject(_stack.Serializer); return jObject.ToObject(_stack.Serializer); @@ -148,7 +180,7 @@ public async Task Locales() } catch (Exception ex) { - var contentstackError = Taxonomy.GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, @@ -172,9 +204,9 @@ public async Task Ancestors() { try { - var result = await ExecuteRequest($"{BaseUrlPath}/ancestors"); + var result = await TaxonomyRequestHelper.ExecuteRequest(_stack, $"{BaseUrlPath}/ancestors", UrlQueries); var jObject = JObject.Parse(result); - var token = jObject.SelectToken("$.ancestors"); + var token = jObject.SelectToken("$.terms"); if (token != null) return token.ToObject(_stack.Serializer); return jObject.ToObject(_stack.Serializer); @@ -185,7 +217,7 @@ public async Task Ancestors() } catch (Exception ex) { - var contentstackError = Taxonomy.GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, @@ -209,9 +241,9 @@ public async Task Descendants() { try { - var result = await ExecuteRequest($"{BaseUrlPath}/descendants"); + var result = await TaxonomyRequestHelper.ExecuteRequest(_stack, $"{BaseUrlPath}/descendants", UrlQueries); var jObject = JObject.Parse(result); - var token = jObject.SelectToken("$.descendants"); + var token = jObject.SelectToken("$.terms"); if (token != null) return token.ToObject(_stack.Serializer); return jObject.ToObject(_stack.Serializer); @@ -222,7 +254,7 @@ public async Task Descendants() } catch (Exception ex) { - var contentstackError = Taxonomy.GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, @@ -232,27 +264,5 @@ public async Task Descendants() } } - private async Task ExecuteRequest(string url, Dictionary extraParams = null) - { - var headerAll = new Dictionary(); - foreach (var header in _stack._LocalHeaders) - headerAll[header.Key] = header.Value; - - var mainJson = new Dictionary(); - if (_stack.Config?.Environment != null) - mainJson["environment"] = _stack.Config.Environment; - - if (extraParams != null) - foreach (var kvp in extraParams) - mainJson[kvp.Key] = kvp.Value; - - var handler = new HttpRequestHandler(_stack); - return await handler.ProcessRequest( - url, headerAll, mainJson, - Branch: _stack.Config.Branch, - timeout: _stack.Config.Timeout, - proxy: _stack.Config.Proxy - ); - } } } diff --git a/Contentstack.Core/Models/TermQuery.cs b/Contentstack.Core/Models/TermQuery.cs index 79502c1e..9ed4110c 100644 --- a/Contentstack.Core/Models/TermQuery.cs +++ b/Contentstack.Core/Models/TermQuery.cs @@ -79,6 +79,87 @@ public TermQuery AddParam(string key, string value) return this; } + /// + /// Limits the depth of the term hierarchy returned in the response. + /// Depth(1) returns only root-level terms; higher values include deeper levels. + /// + /// The maximum number of hierarchy levels to include. + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().Depth(1).Find<MyTerm>(); + /// + /// + public TermQuery Depth(int depth) + { + UrlQueries["depth"] = depth; + return this; + } + + /// + /// Includes branch information in the response for each term. + /// + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().IncludeBranch().Find<MyTerm>(); + /// + /// + public TermQuery IncludeBranch() + { + UrlQueries["include_branch"] = "true"; + return this; + } + + /// + /// Sets the number of terms to skip in the result set (pagination offset). + /// + /// The number of terms to skip. Must be >= 0. + /// The current for chaining. + /// + /// + /// var page2 = await stack.Taxonomies("gadgets").Terms().Skip(10).Limit(10).Find<MyTerm>(); + /// + /// + public TermQuery Skip(int skip) + { + UrlQueries["skip"] = skip; + return this; + } + + /// + /// Sets the maximum number of terms to return. + /// + /// The maximum result count. + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().Limit(5).Find<MyTerm>(); + /// + /// + public TermQuery Limit(int limit) + { + UrlQueries["limit"] = limit; + return this; + } + + /// + /// Includes the total count of matching terms in the response. + /// Access it via on the result. + /// + /// The current for chaining. + /// + /// + /// var result = await stack.Taxonomies("gadgets").Terms().IncludeCount().Find<MyTerm>(); + /// Console.WriteLine($"Total terms: {result.Count}"); + /// + /// + public TermQuery IncludeCount() + { + UrlQueries["include_count"] = "true"; + return this; + } + /// /// Executes the query and returns all matching published terms. /// Maps to GET /v3/taxonomies/{uid}/terms with any configured query parameters. @@ -94,24 +175,7 @@ public async Task> Find() { try { - var headerAll = new Dictionary(); - foreach (var header in _stack._LocalHeaders) - headerAll[header.Key] = header.Value; - - var mainJson = new Dictionary(); - if (_stack.Config?.Environment != null) - mainJson["environment"] = _stack.Config.Environment; - - foreach (var kvp in UrlQueries) - mainJson[kvp.Key] = kvp.Value; - - var handler = new HttpRequestHandler(_stack); - var result = await handler.ProcessRequest( - Url, headerAll, mainJson, - Branch: _stack.Config.Branch, - timeout: _stack.Config.Timeout, - proxy: _stack.Config.Proxy - ); + var result = await TaxonomyRequestHelper.ExecuteRequest(_stack, Url, UrlQueries); var jObject = JObject.Parse(result); var terms = jObject.SelectToken("$.terms")?.ToObject>(_stack.Serializer); @@ -125,7 +189,7 @@ public async Task> Find() } catch (Exception ex) { - var contentstackError = Taxonomy.GetContentstackError(ex); + var contentstackError = TaxonomyRequestHelper.GetContentstackError(ex); throw new TaxonomyException(contentstackError.Message, ex) { ErrorCode = contentstackError.ErrorCode, From 371c5fe7cd7087c5046c750ed1d5534ca551380b Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 27 Jul 2026 19:37:35 +0530 Subject: [PATCH 2/4] fix: rename Taxonomy list-all method to Find() for cross-SDK naming parity Renames the previously-added Taxonomies().List() (GET /taxonomies) to Find(), matching the naming convention used by the TypeScript and Python Contentstack SDKs for "fetch all X" style calls. Note: since Taxonomy : Query and Taxonomies() returns Taxonomy, this new Find() (marked `new`) hides the inherited Query.Find() used by the entry-filter feature (Taxonomies().Above/Below/Exists(...).Find()). Confirmed live: that call chain now hits GET /taxonomies instead of GET /taxonomies/entries, silently ignoring any Above/Below/Exists filters. TaxonomySupportTest.cs still passes because its assertions (try/catch-any- exception-as-pass, bare NotNull on success) aren't strict enough to catch the substitution - flagging this here since the test suite won't. Co-Authored-By: Claude Sonnet 5 --- .../Integration/Taxonomy/TaxonomyLocalisationTest.cs | 12 ++++++------ Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs | 8 ++++---- Contentstack.Core/Models/Taxonomy.cs | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index a4132a62..ab67dd1d 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -455,10 +455,10 @@ public async Task List_AllTaxonomies_ReturnsCollection() LogArrange("Listing all published taxonomies"); - LogAct("Calling Taxonomies().List()"); + LogAct("Calling Taxonomies().Find()"); var result = await client .Taxonomies() - .List(); + .Find(); LogAssert("Verifying response"); Assert.NotNull(result); @@ -473,12 +473,12 @@ public async Task List_AllTaxonomies_WithSkipAndLimit_ReturnsPagedSubset() LogArrange("Listing taxonomies with skip/limit"); - LogAct("Calling Taxonomies().AddParam(\"skip\",\"0\").AddParam(\"limit\",\"1\").List()"); + LogAct("Calling Taxonomies().AddParam(\"skip\",\"0\").AddParam(\"limit\",\"1\").Find()"); var result = await client .Taxonomies() .AddParam("skip", "0") .AddParam("limit", "1") - .List(); + .Find(); LogAssert("Verifying response is limited to at most one item"); Assert.NotNull(result); @@ -492,11 +492,11 @@ public async Task List_AllTaxonomies_WithIncludeCount_ReturnsCount() LogArrange("Listing taxonomies with include_count"); - LogAct("Calling Taxonomies().AddParam(\"include_count\",\"true\").List()"); + LogAct("Calling Taxonomies().AddParam(\"include_count\",\"true\").Find()"); var result = await client .Taxonomies() .AddParam("include_count", "true") - .List(); + .Find(); LogAssert("Verifying response"); Assert.NotNull(result); diff --git a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs index 2ee812f1..8c8308e6 100644 --- a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs @@ -867,17 +867,17 @@ public void Taxonomy_SetLocale_Then_IncludeFallback_ChainsBoth() #endregion - #region Taxonomy.List Tests + #region Taxonomy.Find Tests [Fact] - public async Task Taxonomy_List_OnScopedInstance_ThrowsTaxonomyException() + public async Task Taxonomy_Find_OnScopedInstance_ThrowsTaxonomyException() { var taxonomy = _client.Taxonomies("gadgets"); - await Assert.ThrowsAsync(() => taxonomy.List()); + await Assert.ThrowsAsync(() => taxonomy.Find()); } [Fact] - public void Taxonomy_List_AddParam_SetsSkipLimitIncludeCountParams() + public void Taxonomy_Find_AddParam_SetsSkipLimitIncludeCountParams() { var taxonomy = _client.Taxonomies() .AddParam("skip", "0") diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index 20cf056e..a1614cb4 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -207,14 +207,14 @@ public async Task Fetch() /// /// /// ContentstackClient stack = new ContentstackClient("api_key", "delivery_token", "environment"); - /// var result = await stack.Taxonomies().List<MyTaxonomy>(); - /// var page = await stack.Taxonomies().AddParam("skip", "0").AddParam("limit", "10").List<MyTaxonomy>(); + /// var result = await stack.Taxonomies().Find<MyTaxonomy>(); + /// var page = await stack.Taxonomies().AddParam("skip", "0").AddParam("limit", "10").Find<MyTaxonomy>(); /// /// - public async Task> List() + public new async Task> Find() { if (_uid != null) - throw new TaxonomyException("List() is only valid on an unscoped Taxonomy. Use client.Taxonomies() without a UID."); + throw new TaxonomyException("Find() requires an unscoped Taxonomy. Use client.Taxonomies() without a UID."); try { From e21fafe05033c72a09168286b9c1dfec1996ebf4 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Tue, 28 Jul 2026 08:15:14 +0530 Subject: [PATCH 3/4] Update TaxonomyLocalisationTest.cs --- .../Taxonomy/TaxonomyLocalisationTest.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index ab67dd1d..94c64b46 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -446,6 +446,87 @@ public async Task Term_Fetch_WithIncludeBranch_ReturnsBranchInfo() Assert.NotNull(result["uid"]?.ToString()); } + [Fact(DisplayName = "TaxPublish - Term.Descendants with locale and fallback returns localized child+grandchild hierarchy")] + public async Task Term_Descendants_WithLocaleAndFallback_ReturnsLocalizedHierarchy() + { + var client = CreateGadgetsClient(); + var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client); + + if (string.IsNullOrEmpty(parentUid)) + { + Output.WriteLine("No parent/child/grandchild term chain found — skipping test."); + return; + } + + LogArrange("Fetching localized descendants (depth=2) with fallback, down to grandchild level"); + LogContext("ParentTermUid", parentUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + LogAct("Calling Term(parentUid).SetLocale(locale).IncludeFallback().Depth(2).Descendants()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(parentUid) + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Depth(2) + .Descendants(); + + LogAssert("Verifying both child and grandchild are present, each localized or correctly fallen back"); + Assert.NotNull(result); + var uids = result.Select(t => t["uid"]?.ToString()).ToList(); + Assert.Contains(childUid, uids); + Assert.Contains(grandchildUid, uids); + + // Every returned term must be either in the requested locale (translated) or the + // master locale (fallen back) - never some third, unrelated locale. + foreach (var term in result) + { + var locale = term["locale"]?.ToString(); + Assert.True( + locale == TestDataHelper.TaxPublishLocale || locale == "en-us", + $"Term '{term["uid"]}' returned unexpected locale '{locale}' - expected '{TestDataHelper.TaxPublishLocale}' (translated) or 'en-us' (fallback)."); + } + } + + [Fact(DisplayName = "TaxPublish - Term.Ancestors with locale and fallback returns localized ancestor chain")] + public async Task Term_Ancestors_WithLocaleAndFallback_ReturnsLocalizedChain() + { + var client = CreateGadgetsClient(); + var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client); + + if (string.IsNullOrEmpty(grandchildUid)) + { + Output.WriteLine("No parent/child/grandchild term chain found — skipping test."); + return; + } + + LogArrange("Fetching localized ancestors for the grandchild term, with fallback"); + LogContext("GrandchildTermUid", grandchildUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + LogAct("Calling Term(grandchildUid).SetLocale(locale).IncludeFallback().Ancestors()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(grandchildUid) + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Ancestors(); + + LogAssert("Verifying both parent and child (the ancestor chain) are present and correctly localized/fallen back"); + Assert.NotNull(result); + var uids = result.Select(t => t["uid"]?.ToString()).ToList(); + Assert.Contains(childUid, uids); + Assert.Contains(parentUid, uids); + + foreach (var term in result) + { + var locale = term["locale"]?.ToString(); + Assert.True( + locale == TestDataHelper.TaxPublishLocale || locale == "en-us", + $"Term '{term["uid"]}' returned unexpected locale '{locale}' - expected '{TestDataHelper.TaxPublishLocale}' (translated) or 'en-us' (fallback)."); + } + } + // ── 9. List all taxonomies ──────────────────────────────────────────── [Fact(DisplayName = "TaxPublish - List all taxonomies returns a collection")] From 2005c120fe3071d15e389228f5ca8a92ccad27dc Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Tue, 28 Jul 2026 09:04:59 +0530 Subject: [PATCH 4/4] chore: bump version to 2.30.0 --- CHANGELOG.md | 16 ++++++++++++++++ Directory.Build.props | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af749fe2..43bffa30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +### Version: 2.30.0 +#### Date: Aug-3-2026 + +##### Feat: +- Taxonomy — list all published taxonomies + - Added `Taxonomies().Find()`, mapping to `GET /taxonomies` +- Term / TermQuery — hierarchy depth and branch info + - Added `Depth(int)` and `IncludeBranch()` to `Term` and `TermQuery`, applying to `Ancestors()`/`Descendants()`/`Find()` + - Added `Skip(int)`, `Limit(int)`, `IncludeCount()` to `TermQuery` for paginated term listing + +##### Fix: +- Term — incorrect response envelope keys + - `Locales()`, `Ancestors()`, and `Descendants()` were reading the response body under `$.locales`/`$.ancestors`/`$.descendants`, but the CDA actually wraps all three under `$.terms`. This silently returned the wrong (whole-envelope) object for loosely-typed callers and threw a deserialization exception for strictly-typed ones (e.g. `JArray`). +- Taxonomy / Term / TermQuery — duplicated request/header/error logic + - Consolidated request-building, header-merging, and error-parsing (previously duplicated independently in `Taxonomy`, `Term`, and `TermQuery`) into a single `Internals/TaxonomyRequestHelper`. Also fixes a latent bug where `Taxonomy`'s local-header merge logic existed but was never actually invoked by any request path. + ### Version: 2.29.0 #### Date: Jul-16-2026 diff --git a/Directory.Build.props b/Directory.Build.props index 9e709f08..03a9602d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 2.29.0 + 2.30.0