Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
### Version: 2.30.0
#### Date: Aug-3-2026

##### Feat:
- Taxonomy β€” list all published taxonomies
- Added `Taxonomies().Find<T>()`, mapping to `GET /taxonomies`
- Term / TermQuery β€” hierarchy depth and branch info
- Added `Depth(int)` and `IncludeBranch()` to `Term` and `TermQuery`, applying to `Ancestors<T>()`/`Descendants<T>()`/`Find<T>()`
- Added `Skip(int)`, `Limit(int)`, `IncludeCount()` to `TermQuery` for paginated term listing

##### Fix:
- Term β€” incorrect response envelope keys
- `Locales<T>()`, `Ancestors<T>()`, and `Descendants<T>()` 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -276,5 +274,313 @@ public async Task Term_Descendants_ReturnsDescendantsCollection()
LogAssert("Verifying response");
Assert.NotNull(result);
}

// ── 8. Term hierarchy β€” Depth / IncludeBranch ────────────────────────

/// <summary>
/// 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.
/// </summary>
private async Task<(string parentUid, string childUid, string grandchildUid)> GetTermHierarchyAsync(ContentstackClient client)
{
var terms = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Terms()
.Find<Newtonsoft.Json.Linq.JObject>();

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<Newtonsoft.Json.Linq.JArray>();
var childUid = children?.FirstOrDefault()?["uid"]?.ToString();
if (string.IsNullOrEmpty(childUid)) continue;

var grandchildren = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(childUid)
.Descendants<Newtonsoft.Json.Linq.JArray>();
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<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(parentUid)
.Depth(1)
.Descendants<Newtonsoft.Json.Linq.JArray>();

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<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(parentUid)
.Depth(2)
.Descendants<Newtonsoft.Json.Linq.JArray>();

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<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(grandchildUid)
.Ancestors<Newtonsoft.Json.Linq.JArray>();

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<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Terms()
.Depth(1)
.Find<Newtonsoft.Json.Linq.JObject>();

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<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(termUid)
.IncludeBranch()
.Fetch<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
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<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(parentUid)
.SetLocale(TestDataHelper.TaxPublishLocale)
.IncludeFallback()
.Depth(2)
.Descendants<Newtonsoft.Json.Linq.JArray>();

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<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(grandchildUid)
.SetLocale(TestDataHelper.TaxPublishLocale)
.IncludeFallback()
.Ancestors<Newtonsoft.Json.Linq.JArray>();

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")]
public async Task List_AllTaxonomies_ReturnsCollection()
{
var client = CreateGadgetsClient();

LogArrange("Listing all published taxonomies");

LogAct("Calling Taxonomies().Find<JObject>()");
var result = await client
.Taxonomies()
.Find<Newtonsoft.Json.Linq.JObject>();

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\").Find<JObject>()");
var result = await client
.Taxonomies()
.AddParam("skip", "0")
.AddParam("limit", "1")
.Find<Newtonsoft.Json.Linq.JObject>();

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\").Find<JObject>()");
var result = await client
.Taxonomies()
.AddParam("include_count", "true")
.Find<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
}
}
}
Loading
Loading