Skip to content
Merged
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
### Version: 2.29.0
#### Date: Jul-16-2026

##### Feat:
- Taxonomy / Term / TermQuery — localisation support
- Added `SetLocale(string)`, `IncludeFallback()`, and `AddParam(string, string)` chainable methods to `Taxonomy`, `Term`, and `TermQuery`, matching the convention used by `Asset`, `Entry`, and `AssetLibrary`
- `Taxonomy.Fetch<T>()` and `Term.Fetch<T>()` now read from the shared `UrlQueries` dict — locale and fallback are set via chaining, not method parameters
- Fixed `include_fallback` serialization: value is sent as the string `"true"` instead of a C# `bool` (which serialized as `"True"` and was rejected by the CDA)
- Added `AddParam(string, string)` to `TermQuery` (was missing)

##### Fix:
- Taxonomy / Term / TermQuery — branch handling
- Removed `?? "main"` fallback on `Config.Branch` across `Taxonomy.Fetch<T>()`, `Term.ExecuteRequest()`, and `TermQuery.Find<T>()`. The fallback was injecting `branch=main` into every CDA request when branching was not configured, causing `422 Branch not found` errors. Branch is now passed directly from `Config.Branch`, consistent with `Asset` and `Entry`.
- Taxonomy / Term / TermQuery — structured error propagation
- Replaced `TaxonomyException.CreateForProcessingError(ex)` with `GetContentstackError(ex)` in all catch blocks across `Taxonomy.Fetch<T>()`, `Term.Fetch<T>()`, `Term.Locales<T>()`, `Term.Ancestors<T>()`, `Term.Descendants<T>()`, and `TermQuery.Find<T>()`. On 4xx responses, `ErrorCode`, `StatusCode`, and `Errors` are now correctly populated from the API response body, consistent with `Asset` and `Entry` error handling.

##### Migration:
- `Taxonomies("uid").Fetch<T>("hi-in")` → `Taxonomies("uid").SetLocale("hi-in").Fetch<T>()`
- `Term("uid").Fetch<T>("hi-in", includeFallback: true)` → `Term("uid").SetLocale("hi-in").IncludeFallback().Fetch<T>()`

### Version: 2.28.0
#### Date: Jun-24-2026

Expand Down
22 changes: 17 additions & 5 deletions Contentstack.Core.Tests/Helpers/TestDataHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,31 @@ static TestDataHelper()
#endregion

#region Taxonomy

/// <summary>
/// Gets the taxonomy term for USA state (e.g., "california")
/// </summary>
public static string TaxUsaState =>
public static string TaxUsaState =>
GetRequiredConfig("TAX_USA_STATE");

/// <summary>
/// Gets the taxonomy term for India state (e.g., "maharashtra")
/// </summary>
public static string TaxIndiaState =>
public static string TaxIndiaState =>
GetRequiredConfig("TAX_INDIA_STATE");


/// <summary>
/// UID of the published taxonomy to use in localization tests (e.g. "gadgets").
/// </summary>
public static string TaxPublishTaxonomyUid =>
GetRequiredConfig("TAX_PUBLISH_TAXONOMY_UID");

/// <summary>
/// Locale code used for localized taxonomy/term delivery tests (e.g. "hi-in").
/// </summary>
public static string TaxPublishLocale =>
GetRequiredConfig("TAX_PUBLISH_LOCALE");

#endregion

#region Live Preview
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
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
{
/// <summary>
/// Integration tests for localized taxonomy and term delivery (CDA).
/// Covers: Taxonomy.Fetch(locale), TermQuery.SetLocale/IncludeFallback/Find,
/// Term.Fetch(locale), Term.Locales, Term.Ancestors, Term.Descendants.
/// Stack: gadgets taxonomy, locales en-us / hi-in.
/// </summary>
[Trait("Category", "TaxonomyLocalisation")]
public class TaxonomyLocalisationTest : IntegrationTestBase
{
public TaxonomyLocalisationTest(ITestOutputHelper output) : base(output) { }

/// <summary>
/// Creates a client scoped to the gadgets taxonomy stack.
/// Host must be set from config — the test stack lives on a non-prod CDN.
/// </summary>
private ContentstackClient CreateGadgetsClient()
{
var options = new ContentstackOptions
{
ApiKey = TestDataHelper.ApiKey,
DeliveryToken = TestDataHelper.DeliveryToken,
Environment = TestDataHelper.Environment,
Host = TestDataHelper.Host
};
var client = new ContentstackClient(options);
client.Plugins.Add(new RequestLoggingPlugin(TestOutput));
return client;
}

// ── 1. Fetch localized taxonomy ──────────────────────────────────────

[Fact(DisplayName = "TaxPublish - Fetch taxonomy returns object with uid and name")]
public async Task Fetch_Taxonomy_MasterLocale_ReturnsValidObject()
{
LogArrange("Fetching taxonomy without locale (master)");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);

var client = CreateGadgetsClient();

LogAct("Calling Taxonomies(uid).Fetch<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Fetch<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
Assert.NotNull(result["uid"]?.ToString());
Assert.Equal(TestDataHelper.TaxPublishTaxonomyUid, result["uid"]?.ToString());
}

[Fact(DisplayName = "TaxPublish - Fetch taxonomy with locale returns localized name")]
public async Task Fetch_Taxonomy_WithLocale_ReturnsLocalizedName()
{
LogArrange("Fetching taxonomy with locale");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("Locale", TestDataHelper.TaxPublishLocale);

var client = CreateGadgetsClient();

LogAct("Calling Taxonomies(uid).SetLocale(locale).Fetch<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.SetLocale(TestDataHelper.TaxPublishLocale)
.Fetch<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
Assert.NotNull(result["uid"]?.ToString());
// The name should differ from the master locale (en-us) value
Assert.NotNull(result["name"]?.ToString());
}

// ── 2. Find all taxonomies ────────────────────────────────────────────

[Fact(DisplayName = "TaxPublish - Find all terms without locale returns master-locale terms")]
public async Task Find_AllTaxonomies_ReturnsCollection()
{
LogArrange("Fetching all terms in master locale (no locale filter)");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);

var client = CreateGadgetsClient();

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

LogAssert("Verifying response");
Assert.NotNull(result);
Assert.NotNull(result.Items);
Assert.True(result.Items.Any());
}

// ── 3. Find terms with locale ─────────────────────────────────────────

[Fact(DisplayName = "TaxPublish - Find terms with locale returns localized terms")]
public async Task Find_Terms_WithLocale_ReturnsLocalizedTerms()
{
LogArrange("Finding terms with locale");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("Locale", TestDataHelper.TaxPublishLocale);

var client = CreateGadgetsClient();

LogAct("Calling Terms().SetLocale(locale).Find<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Terms()
.SetLocale(TestDataHelper.TaxPublishLocale)
.Find<Newtonsoft.Json.Linq.JObject>();

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

[Fact(DisplayName = "TaxPublish - Find terms with locale and fallback returns terms")]
public async Task Find_Terms_WithLocaleAndFallback_ReturnsTerms()
{
LogArrange("Finding terms with locale and include_fallback");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("Locale", TestDataHelper.TaxPublishLocale);

var client = CreateGadgetsClient();

LogAct("Calling Terms().SetLocale(locale).IncludeFallback().Find<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Terms()
.SetLocale(TestDataHelper.TaxPublishLocale)
.IncludeFallback()
.Find<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
Assert.NotNull(result.Items);
Assert.True(result.Items.Any());
}

// ── 4–7. Single term methods ──────────────────────────────────────────

/// <summary>
/// Fetches the first available term UID from the gadgets taxonomy to use in subsequent tests.
/// </summary>
private async Task<string> GetFirstTermUidAsync(ContentstackClient client)
{
var terms = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Terms()
.SetLocale(TestDataHelper.TaxPublishLocale)
.IncludeFallback()
.Find<Newtonsoft.Json.Linq.JObject>();

return terms?.Items?.FirstOrDefault()?["uid"]?.ToString();
}

[Fact(DisplayName = "TaxPublish - Fetch single term with locale returns localized term")]
public async Task Fetch_SingleTerm_WithLocale_ReturnsLocalizedTerm()
{
var client = CreateGadgetsClient();
var termUid = await GetFirstTermUidAsync(client);

if (string.IsNullOrEmpty(termUid))
{
Output.WriteLine("No term UID found — skipping test.");
return;
}

LogArrange("Fetching single term with locale");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("TermUid", termUid);
LogContext("Locale", TestDataHelper.TaxPublishLocale);

LogAct("Calling Term(termUid).SetLocale(locale).IncludeFallback().Fetch<JObject>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(termUid)
.SetLocale(TestDataHelper.TaxPublishLocale)
.IncludeFallback()
.Fetch<Newtonsoft.Json.Linq.JObject>();

LogAssert("Verifying response");
Assert.NotNull(result);
Assert.NotNull(result["uid"]?.ToString());
Assert.Equal(termUid, result["uid"]?.ToString());
}

[Fact(DisplayName = "TaxPublish - Term.Locales returns locales collection")]
public async Task Term_Locales_ReturnsLocalesCollection()
{
var client = CreateGadgetsClient();
var termUid = await GetFirstTermUidAsync(client);

if (string.IsNullOrEmpty(termUid))
{
Output.WriteLine("No term UID found — skipping test.");
return;
}

LogArrange("Fetching all locales for a term");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("TermUid", termUid);

LogAct("Calling Term(termUid).Locales<JArray>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(termUid)
.Locales<Newtonsoft.Json.Linq.JToken>();

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

[Fact(DisplayName = "TaxPublish - Term.Ancestors returns ancestors collection")]
public async Task Term_Ancestors_ReturnsAncestorsCollection()
{
var client = CreateGadgetsClient();
var termUid = await GetFirstTermUidAsync(client);

if (string.IsNullOrEmpty(termUid))
{
Output.WriteLine("No term UID found — skipping test.");
return;
}

LogArrange("Fetching ancestors for a term");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("TermUid", termUid);

LogAct("Calling Term(termUid).Ancestors<JToken>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(termUid)
.Ancestors<Newtonsoft.Json.Linq.JToken>();

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

[Fact(DisplayName = "TaxPublish - Term.Descendants returns descendants collection")]
public async Task Term_Descendants_ReturnsDescendantsCollection()
{
var client = CreateGadgetsClient();
var termUid = await GetFirstTermUidAsync(client);

if (string.IsNullOrEmpty(termUid))
{
Output.WriteLine("No term UID found — skipping test.");
return;
}

LogArrange("Fetching descendants for a term");
LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid);
LogContext("TermUid", termUid);

LogAct("Calling Term(termUid).Descendants<JToken>()");
var result = await client
.Taxonomies(TestDataHelper.TaxPublishTaxonomyUid)
.Term(termUid)
.Descendants<Newtonsoft.Json.Linq.JToken>();

LogAssert("Verifying response");
Assert.NotNull(result);
}
}
}
12 changes: 6 additions & 6 deletions Contentstack.Core.Unit.Tests/QueryUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2437,8 +2437,8 @@ public void Query_WithTaxonomyInstance_Setup_VerifiesConfig()

// Act - Just verify setup - Taxonomy extends Query, so it has QueryInstance properties
var taxonomyType = typeof(Taxonomy);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.Public | BindingFlags.Instance);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.NonPublic | BindingFlags.Instance);

// Assert
Assert.NotNull(taxonomy);
Expand All @@ -2460,8 +2460,8 @@ public void Query_WithTaxonomyInstanceAndEnvironment_Setup_VerifiesConfig()

// Act - Just verify setup
var taxonomyType = typeof(Taxonomy);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.Public | BindingFlags.Instance);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.NonPublic | BindingFlags.Instance);

// Assert
Assert.NotNull(taxonomy);
Expand All @@ -2484,8 +2484,8 @@ public void Query_WithTaxonomyInstanceAndBranch_Setup_VerifiesConfig()

// Act - Just verify setup
var taxonomyType = typeof(Taxonomy);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.Public | BindingFlags.Instance);
var stackProperty = taxonomyType.GetProperty("Stack",
BindingFlags.NonPublic | BindingFlags.Instance);

// Assert
Assert.NotNull(taxonomy);
Expand Down
Loading
Loading