Skip to content

Commit 9981036

Browse files
authored
Merge pull request #197 from contentstack/feat/taxonomy-27-07-2026
DX | 03-08-2026 | Release
2 parents ba679e1 + 3499f37 commit 9981036

10 files changed

Lines changed: 1181 additions & 302 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
### Version: 2.30.0
2+
#### Date: Aug-3-2026
3+
4+
##### Feat:
5+
- Taxonomy — list all published taxonomies
6+
- Added `Taxonomies().Find<T>()`, mapping to `GET /taxonomies`
7+
- Term / TermQuery — hierarchy depth and branch info
8+
- Added `Depth(int)` and `IncludeBranch()` to `Term` and `TermQuery`, applying to `Ancestors<T>()`/`Descendants<T>()`/`Find<T>()`
9+
- Added `Skip(int)`, `Limit(int)`, `IncludeCount()` to `TermQuery` for paginated term listing
10+
11+
##### Fix:
12+
- Term — incorrect response envelope keys
13+
- `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`).
14+
- Taxonomy / Term / TermQuery — duplicated request/header/error logic
15+
- 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.
16+
117
### Version: 2.29.0
218
#### Date: Jul-16-2026
319

Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs

Lines changed: 308 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
using Xunit;
66
using Xunit.Abstractions;
77
using Contentstack.Core.Configuration;
8-
using Contentstack.Core.Models;
9-
using Contentstack.Core.Internals;
108
using Contentstack.Core.Tests.Helpers;
119

1210
namespace Contentstack.Core.Tests.Integration.Taxonomy
@@ -276,5 +274,313 @@ public async Task Term_Descendants_ReturnsDescendantsCollection()
276274
LogAssert("Verifying response");
277275
Assert.NotNull(result);
278276
}
277+
278+
// ── 8. Term hierarchy — Depth / IncludeBranch ────────────────────────
279+
280+
/// <summary>
281+
/// Walks the gadgets taxonomy to find a term with at least one descendant that itself
282+
/// has a descendant — i.e. a parent/child/grandchild chain — for hierarchy-depth tests.
283+
/// Returns (null, null, null) if no such chain exists in the fixture data.
284+
/// </summary>
285+
private async Task<(string parentUid, string childUid, string grandchildUid)> GetTermHierarchyAsync(ContentstackClient client)
286+
{
287+
var terms = await client
288+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
289+
.Terms()
290+
.Find<Newtonsoft.Json.Linq.JObject>();
291+
292+
foreach (var candidateParent in terms.Items)
293+
{
294+
var parentUid = candidateParent["uid"]?.ToString();
295+
if (string.IsNullOrEmpty(parentUid)) continue;
296+
297+
var children = await client
298+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
299+
.Term(parentUid)
300+
.Descendants<Newtonsoft.Json.Linq.JArray>();
301+
var childUid = children?.FirstOrDefault()?["uid"]?.ToString();
302+
if (string.IsNullOrEmpty(childUid)) continue;
303+
304+
var grandchildren = await client
305+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
306+
.Term(childUid)
307+
.Descendants<Newtonsoft.Json.Linq.JArray>();
308+
var grandchildUid = grandchildren?.FirstOrDefault()?["uid"]?.ToString();
309+
if (string.IsNullOrEmpty(grandchildUid)) continue;
310+
311+
return (parentUid, childUid, grandchildUid);
312+
}
313+
return (null, null, null);
314+
}
315+
316+
[Fact(DisplayName = "TaxPublish - Term.Descendants with depth=1 returns only direct children")]
317+
public async Task Term_Descendants_WithDepth1_ReturnsOnlyDirectChildren()
318+
{
319+
var client = CreateGadgetsClient();
320+
var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client);
321+
322+
if (string.IsNullOrEmpty(parentUid))
323+
{
324+
Output.WriteLine("No parent/child/grandchild term chain found — skipping test.");
325+
return;
326+
}
327+
328+
LogArrange("Fetching direct-child-only descendants (depth=1)");
329+
LogContext("ParentTermUid", parentUid);
330+
331+
LogAct("Calling Term(parentUid).Depth(1).Descendants<JArray>()");
332+
var result = await client
333+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
334+
.Term(parentUid)
335+
.Depth(1)
336+
.Descendants<Newtonsoft.Json.Linq.JArray>();
337+
338+
LogAssert("Verifying only the direct child is present, not the grandchild");
339+
Assert.NotNull(result);
340+
var uids = result.Select(t => t["uid"]?.ToString()).ToList();
341+
Assert.Contains(childUid, uids);
342+
Assert.DoesNotContain(grandchildUid, uids);
343+
}
344+
345+
[Fact(DisplayName = "TaxPublish - Term.Descendants with depth=2 includes grandchildren")]
346+
public async Task Term_Descendants_WithDepth2_IncludesGrandchildren()
347+
{
348+
var client = CreateGadgetsClient();
349+
var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client);
350+
351+
if (string.IsNullOrEmpty(parentUid))
352+
{
353+
Output.WriteLine("No parent/child/grandchild term chain found — skipping test.");
354+
return;
355+
}
356+
357+
LogArrange("Fetching descendants two levels deep (depth=2)");
358+
LogContext("ParentTermUid", parentUid);
359+
360+
LogAct("Calling Term(parentUid).Depth(2).Descendants<JArray>()");
361+
var result = await client
362+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
363+
.Term(parentUid)
364+
.Depth(2)
365+
.Descendants<Newtonsoft.Json.Linq.JArray>();
366+
367+
LogAssert("Verifying both the child and grandchild are present");
368+
Assert.NotNull(result);
369+
var uids = result.Select(t => t["uid"]?.ToString()).ToList();
370+
Assert.Contains(childUid, uids);
371+
Assert.Contains(grandchildUid, uids);
372+
}
373+
374+
[Fact(DisplayName = "TaxPublish - Term.Ancestors for a grandchild term returns the full parent chain")]
375+
public async Task Term_Ancestors_ForGrandchildTerm_ReturnsFullChain()
376+
{
377+
var client = CreateGadgetsClient();
378+
var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client);
379+
380+
if (string.IsNullOrEmpty(grandchildUid))
381+
{
382+
Output.WriteLine("No parent/child/grandchild term chain found — skipping test.");
383+
return;
384+
}
385+
386+
LogArrange("Fetching ancestors for the grandchild term");
387+
LogContext("GrandchildTermUid", grandchildUid);
388+
389+
LogAct("Calling Term(grandchildUid).Ancestors<JArray>()");
390+
var result = await client
391+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
392+
.Term(grandchildUid)
393+
.Ancestors<Newtonsoft.Json.Linq.JArray>();
394+
395+
LogAssert("Verifying both the parent and child (grandparent chain) are present");
396+
Assert.NotNull(result);
397+
var uids = result.Select(t => t["uid"]?.ToString()).ToList();
398+
Assert.Contains(childUid, uids);
399+
Assert.Contains(parentUid, uids);
400+
}
401+
402+
[Fact(DisplayName = "TaxPublish - TermQuery.Find with depth limits the returned hierarchy")]
403+
public async Task TermQuery_Find_WithDepth_LimitsHierarchyDepth()
404+
{
405+
var client = CreateGadgetsClient();
406+
407+
LogArrange("Finding terms with depth=1");
408+
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
409+
410+
LogAct("Calling Terms().Depth(1).Find<JObject>()");
411+
var result = await client
412+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
413+
.Terms()
414+
.Depth(1)
415+
.Find<Newtonsoft.Json.Linq.JObject>();
416+
417+
LogAssert("Verifying response");
418+
Assert.NotNull(result);
419+
Assert.NotNull(result.Items);
420+
}
421+
422+
[Fact(DisplayName = "TaxPublish - Term.Fetch with IncludeBranch returns branch info")]
423+
public async Task Term_Fetch_WithIncludeBranch_ReturnsBranchInfo()
424+
{
425+
var client = CreateGadgetsClient();
426+
var termUid = await GetFirstTermUidAsync(client);
427+
428+
if (string.IsNullOrEmpty(termUid))
429+
{
430+
Output.WriteLine("No term UID found — skipping test.");
431+
return;
432+
}
433+
434+
LogArrange("Fetching a term with branch info included");
435+
LogContext("TermUid", termUid);
436+
437+
LogAct("Calling Term(termUid).IncludeBranch().Fetch<JObject>()");
438+
var result = await client
439+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
440+
.Term(termUid)
441+
.IncludeBranch()
442+
.Fetch<Newtonsoft.Json.Linq.JObject>();
443+
444+
LogAssert("Verifying response");
445+
Assert.NotNull(result);
446+
Assert.NotNull(result["uid"]?.ToString());
447+
}
448+
449+
[Fact(DisplayName = "TaxPublish - Term.Descendants with locale and fallback returns localized child+grandchild hierarchy")]
450+
public async Task Term_Descendants_WithLocaleAndFallback_ReturnsLocalizedHierarchy()
451+
{
452+
var client = CreateGadgetsClient();
453+
var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client);
454+
455+
if (string.IsNullOrEmpty(parentUid))
456+
{
457+
Output.WriteLine("No parent/child/grandchild term chain found — skipping test.");
458+
return;
459+
}
460+
461+
LogArrange("Fetching localized descendants (depth=2) with fallback, down to grandchild level");
462+
LogContext("ParentTermUid", parentUid);
463+
LogContext("Locale", TestDataHelper.TaxPublishLocale);
464+
465+
LogAct("Calling Term(parentUid).SetLocale(locale).IncludeFallback().Depth(2).Descendants<JArray>()");
466+
var result = await client
467+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
468+
.Term(parentUid)
469+
.SetLocale(TestDataHelper.TaxPublishLocale)
470+
.IncludeFallback()
471+
.Depth(2)
472+
.Descendants<Newtonsoft.Json.Linq.JArray>();
473+
474+
LogAssert("Verifying both child and grandchild are present, each localized or correctly fallen back");
475+
Assert.NotNull(result);
476+
var uids = result.Select(t => t["uid"]?.ToString()).ToList();
477+
Assert.Contains(childUid, uids);
478+
Assert.Contains(grandchildUid, uids);
479+
480+
// Every returned term must be either in the requested locale (translated) or the
481+
// master locale (fallen back) - never some third, unrelated locale.
482+
foreach (var term in result)
483+
{
484+
var locale = term["locale"]?.ToString();
485+
Assert.True(
486+
locale == TestDataHelper.TaxPublishLocale || locale == "en-us",
487+
$"Term '{term["uid"]}' returned unexpected locale '{locale}' - expected '{TestDataHelper.TaxPublishLocale}' (translated) or 'en-us' (fallback).");
488+
}
489+
}
490+
491+
[Fact(DisplayName = "TaxPublish - Term.Ancestors with locale and fallback returns localized ancestor chain")]
492+
public async Task Term_Ancestors_WithLocaleAndFallback_ReturnsLocalizedChain()
493+
{
494+
var client = CreateGadgetsClient();
495+
var (parentUid, childUid, grandchildUid) = await GetTermHierarchyAsync(client);
496+
497+
if (string.IsNullOrEmpty(grandchildUid))
498+
{
499+
Output.WriteLine("No parent/child/grandchild term chain found — skipping test.");
500+
return;
501+
}
502+
503+
LogArrange("Fetching localized ancestors for the grandchild term, with fallback");
504+
LogContext("GrandchildTermUid", grandchildUid);
505+
LogContext("Locale", TestDataHelper.TaxPublishLocale);
506+
507+
LogAct("Calling Term(grandchildUid).SetLocale(locale).IncludeFallback().Ancestors<JArray>()");
508+
var result = await client
509+
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
510+
.Term(grandchildUid)
511+
.SetLocale(TestDataHelper.TaxPublishLocale)
512+
.IncludeFallback()
513+
.Ancestors<Newtonsoft.Json.Linq.JArray>();
514+
515+
LogAssert("Verifying both parent and child (the ancestor chain) are present and correctly localized/fallen back");
516+
Assert.NotNull(result);
517+
var uids = result.Select(t => t["uid"]?.ToString()).ToList();
518+
Assert.Contains(childUid, uids);
519+
Assert.Contains(parentUid, uids);
520+
521+
foreach (var term in result)
522+
{
523+
var locale = term["locale"]?.ToString();
524+
Assert.True(
525+
locale == TestDataHelper.TaxPublishLocale || locale == "en-us",
526+
$"Term '{term["uid"]}' returned unexpected locale '{locale}' - expected '{TestDataHelper.TaxPublishLocale}' (translated) or 'en-us' (fallback).");
527+
}
528+
}
529+
530+
// ── 9. List all taxonomies ────────────────────────────────────────────
531+
532+
[Fact(DisplayName = "TaxPublish - List all taxonomies returns a collection")]
533+
public async Task List_AllTaxonomies_ReturnsCollection()
534+
{
535+
var client = CreateGadgetsClient();
536+
537+
LogArrange("Listing all published taxonomies");
538+
539+
LogAct("Calling Taxonomies().Find<JObject>()");
540+
var result = await client
541+
.Taxonomies()
542+
.Find<Newtonsoft.Json.Linq.JObject>();
543+
544+
LogAssert("Verifying response");
545+
Assert.NotNull(result);
546+
Assert.NotNull(result.Items);
547+
Assert.True(result.Items.Any());
548+
}
549+
550+
[Fact(DisplayName = "TaxPublish - List all taxonomies with skip/limit returns a paged subset")]
551+
public async Task List_AllTaxonomies_WithSkipAndLimit_ReturnsPagedSubset()
552+
{
553+
var client = CreateGadgetsClient();
554+
555+
LogArrange("Listing taxonomies with skip/limit");
556+
557+
LogAct("Calling Taxonomies().AddParam(\"skip\",\"0\").AddParam(\"limit\",\"1\").Find<JObject>()");
558+
var result = await client
559+
.Taxonomies()
560+
.AddParam("skip", "0")
561+
.AddParam("limit", "1")
562+
.Find<Newtonsoft.Json.Linq.JObject>();
563+
564+
LogAssert("Verifying response is limited to at most one item");
565+
Assert.NotNull(result);
566+
Assert.True(result.Items.Count() <= 1);
567+
}
568+
569+
[Fact(DisplayName = "TaxPublish - List all taxonomies with include_count returns a count")]
570+
public async Task List_AllTaxonomies_WithIncludeCount_ReturnsCount()
571+
{
572+
var client = CreateGadgetsClient();
573+
574+
LogArrange("Listing taxonomies with include_count");
575+
576+
LogAct("Calling Taxonomies().AddParam(\"include_count\",\"true\").Find<JObject>()");
577+
var result = await client
578+
.Taxonomies()
579+
.AddParam("include_count", "true")
580+
.Find<Newtonsoft.Json.Linq.JObject>();
581+
582+
LogAssert("Verifying response");
583+
Assert.NotNull(result);
584+
}
279585
}
280586
}

0 commit comments

Comments
 (0)