From 885c63fb81aa3992722a5ad984c2916264b3fa02 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:26:35 +0530 Subject: [PATCH 01/11] Moved newtonsoft json to system text json --- .github/workflows/unit-test.yml | 4 +- .../Contentstack.Core.Tests.csproj | 10 +- .../Helpers/EntryFactory.cs | 2 +- .../AssetManagementComprehensiveTest.cs | 2 +- .../MetadataBranchComprehensiveTest.cs | 2 +- .../CachingTests/CachePersistenceTest.cs | 2 +- .../ClientTests/ContentstackClientTest.cs | 2 +- .../ContentTypeOperationsTest.cs | 16 +-- .../ContentTypeTests/ContentTypeQueryTest.cs | 6 +- .../EntryTests/EntryIncludeExtendedTest.cs | 2 +- .../EntryOperationsComprehensiveTest.cs | 2 +- .../FieldProjectionAndReferencesTest.cs | 3 +- .../GlobalFieldsComprehensiveTest.cs | 6 +- .../NestedGlobalFieldsTest.cs | 6 +- .../HeaderTests/HeaderManagementTest.cs | 2 +- .../ImageDeliveryComprehensiveTest.cs | 2 +- .../JSONRTETests/JsonRteEmbeddedItemsTest.cs | 8 +- .../LocaleFallbackChainTest.cs | 2 +- .../LocalizationExtendedTest.cs | 2 +- .../ModularBlocksComprehensiveTest.cs | 4 +- .../PaginationComprehensiveTest.cs | 2 +- .../PerformanceLargeDatasetsTest.cs | 2 +- .../QueryEncodingComprehensiveTest.cs | 2 +- .../QueryTests/AdvancedQueryFeaturesTest.cs | 2 +- .../QueryTests/ComplexFieldQueriesTest.cs | 2 +- .../ComplexQueryCombinationsTest.cs | 2 +- .../EntryQueryablesComprehensiveTest.cs | 2 +- .../QueryTests/QueryIncludeExtendedTest.cs | 2 +- .../QueryOperatorsComprehensiveTest.cs | 2 +- .../DeepReferencesComprehensiveTest.cs | 3 +- .../ReferenceTests/MultiReferenceTest.cs | 2 +- .../RetryTests/RetryIntegrationTest.cs | 20 ++-- .../StackOperationsComprehensiveTest.cs | 2 +- .../SyncTests/ExtendedSyncApiTest.cs | 2 +- .../SyncTests/SyncApiComprehensiveTest.cs | 2 +- .../Taxonomy/TaxonomySupportTest.cs | 2 +- .../Mocks/EntryTestHelper.cs | 8 +- Contentstack.Core.Tests/Mocks/MockResponse.cs | 18 +-- Contentstack.Core.Tests/Models/SourceModel.cs | 27 +++-- Contentstack.Core.Tests/Models/TestPlugin.cs | 62 +++++----- Contentstack.Core.Tests/StackConfig.cs | 8 +- .../AssetLibraryUnitTests.cs | 16 +-- .../Contentstack.Core.Unit.Tests.csproj | 5 +- .../ContentstackClientUnitTests.cs | 43 +++---- .../ContentstackOptionsUnitTests.cs | 12 +- .../EntryUnitTests.cs | 32 ++--- .../JsonConverterUnitTests.cs | 57 +++++---- .../LivePreviewConfigUnitTests.cs | 1 - .../Mokes/MockHttpHandler.cs | 5 +- .../Mokes/MockInfrastructureTest.cs | 14 +-- .../Mokes/MockResponse.cs | 10 +- .../SyncStackUnitTests.cs | 8 +- .../WebRequestAsyncExtensionsUnitTests.cs | 26 ++-- .../Attributes/CSJsonConverterAttribute.cs | 3 +- .../Configuration/ContentstackOptions.cs | 21 ++++ .../Configuration/LivePreviewConfig.cs | 4 +- Contentstack.Core/Contentstack.Core.csproj | 3 +- Contentstack.Core/ContentstackClient.cs | 66 +++++------ .../Internals/ApiErrorBodyParser.cs | 73 ++++++++++++ .../Internals/AssetJsonConverter.cs | 23 ++-- .../Internals/ContentstackConvert.cs | 17 +-- .../Internals/ContentstackJsonDefaults.cs | 26 ++++ .../Internals/EntryJsonConverter.cs | 27 +++-- .../Internals/HttpRequestHandler.cs | 7 +- .../Internals/JsonNodeConversion.cs | 111 ++++++++++++++++++ .../Internals/JsonObjectMerge.cs | 71 +++++++++++ Contentstack.Core/Internals/StackOutput.cs | 18 +++ Contentstack.Core/Models/Asset.cs | 63 ++++------ Contentstack.Core/Models/AssetLibrary.cs | 59 ++++------ Contentstack.Core/Models/ContentType.cs | 23 +--- .../Models/ContentstackCollection.cs | 57 ++++++++- Contentstack.Core/Models/Entry.cs | 59 +++------- Contentstack.Core/Models/GlobalField.cs | 23 +--- Contentstack.Core/Models/GlobalFieldQuery.cs | 22 +--- Contentstack.Core/Models/Query.cs | 77 ++++++------ Contentstack.Core/Models/SyncStack.cs | 16 +-- Contentstack.Core/Models/Taxonomy.cs | 28 +---- Directory.Build.props | 2 +- 78 files changed, 839 insertions(+), 548 deletions(-) create mode 100644 Contentstack.Core/Internals/ApiErrorBodyParser.cs create mode 100644 Contentstack.Core/Internals/ContentstackJsonDefaults.cs create mode 100644 Contentstack.Core/Internals/JsonNodeConversion.cs create mode 100644 Contentstack.Core/Internals/JsonObjectMerge.cs diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index bd228aca..dec61dd6 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4.2.2 - - name: Setup .NET 7.0 + - name: Setup .NET uses: actions/setup-dotnet@v4.3.0 with: - dotnet-version: '7.0.x' + dotnet-version: '10.0.x' - name: Restore dependencies run: dotnet restore Contentstack.Net.sln - name: Build solution diff --git a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj index 85968f71..4dcac804 100644 --- a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj +++ b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 false $(Version) @@ -27,17 +27,9 @@ - - - - - - ..\Contentstack.Core\bin\Debug\Contentstack.Core.dll - - diff --git a/Contentstack.Core.Tests/Helpers/EntryFactory.cs b/Contentstack.Core.Tests/Helpers/EntryFactory.cs index 33e9d81a..d980d123 100644 --- a/Contentstack.Core.Tests/Helpers/EntryFactory.cs +++ b/Contentstack.Core.Tests/Helpers/EntryFactory.cs @@ -228,7 +228,7 @@ public async Task> FetchFirstEntryAsync(string con /// /// Content type UID /// Count result - public async Task CountEntriesAsync(string contentTypeUid) + public async Task CountEntriesAsync(string contentTypeUid) { return await CreateQuery(contentTypeUid).Count(); } diff --git a/Contentstack.Core.Tests/Integration/AssetTests/AssetManagementComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/AssetTests/AssetManagementComprehensiveTest.cs index 59c251dc..f70c1b0f 100644 --- a/Contentstack.Core.Tests/Integration/AssetTests/AssetManagementComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/AssetTests/AssetManagementComprehensiveTest.cs @@ -560,7 +560,7 @@ public async Task Asset_Tags_Available() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/BranchTests/MetadataBranchComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/BranchTests/MetadataBranchComprehensiveTest.cs index 9889aa01..ef815989 100644 --- a/Contentstack.Core.Tests/Integration/BranchTests/MetadataBranchComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/BranchTests/MetadataBranchComprehensiveTest.cs @@ -382,7 +382,7 @@ public async Task Metadata_Performance_WithOwner() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/CachingTests/CachePersistenceTest.cs b/Contentstack.Core.Tests/Integration/CachingTests/CachePersistenceTest.cs index a14dddbd..8675d6a9 100644 --- a/Contentstack.Core.Tests/Integration/CachingTests/CachePersistenceTest.cs +++ b/Contentstack.Core.Tests/Integration/CachingTests/CachePersistenceTest.cs @@ -439,7 +439,7 @@ public async Task Cache_DifferentProjections_IndependentCache() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ClientTests/ContentstackClientTest.cs b/Contentstack.Core.Tests/Integration/ClientTests/ContentstackClientTest.cs index 25c510ff..2614e0a7 100644 --- a/Contentstack.Core.Tests/Integration/ClientTests/ContentstackClientTest.cs +++ b/Contentstack.Core.Tests/Integration/ClientTests/ContentstackClientTest.cs @@ -17,7 +17,7 @@ public ContentstackClientTest(ITestOutputHelper output) : base(output) { } - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeOperationsTest.cs b/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeOperationsTest.cs index 81b5c969..f6e290d9 100644 --- a/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeOperationsTest.cs +++ b/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeOperationsTest.cs @@ -6,7 +6,7 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using System.Collections; using Xunit.Abstractions; @@ -165,10 +165,10 @@ public async Task ContentType_FetchSingleContentType_ReturnsSchema() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); // Schema should contain uid TestAssert.True(schema.ContainsKey("uid")); - TestAssert.Equal(TestDataHelper.SimpleContentTypeUid, schema["uid"]?.ToString()); + TestAssert.Equal(TestDataHelper.SimpleContentTypeUid, schema["uid"]?.GetValue()); } [Fact(DisplayName = "Content Type - Content Type Fetch With Global Fields Includes Global Field Schema")] @@ -194,7 +194,7 @@ public async Task ContentType_FetchWithGlobalFields_IncludesGlobalFieldSchema() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); } [Fact(DisplayName = "Content Type - Content Type Fetch Complex Type Contains Expected Fields")] @@ -216,7 +216,7 @@ public async Task ContentType_FetchComplexType_ContainsExpectedFields() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); // Should have schema field TestAssert.True(schema.ContainsKey("schema")); } @@ -287,7 +287,7 @@ public async Task ContentType_Schema_ContainsUid() TestAssert.NotNull(schema); TestAssert.True(schema.ContainsKey("uid")); - TestAssert.Equal(TestDataHelper.MediumContentTypeUid, schema["uid"].ToString()); + TestAssert.Equal(TestDataHelper.MediumContentTypeUid, schema["uid"]?.GetValue()); } [Fact(DisplayName = "Content Type - Content Type Schema Contains Schema Definition")] @@ -310,7 +310,7 @@ public async Task ContentType_Schema_ContainsSchemaDefinition() TestAssert.NotNull(schema); TestAssert.True(schema.ContainsKey("schema")); - var schemaArray = schema["schema"] as JArray; + var schemaArray = schema["schema"] as JsonArray; TestAssert.NotNull(schemaArray); TestAssert.True(schemaArray.Count > 0, "Schema should contain field definitions"); } @@ -402,7 +402,7 @@ public async Task ContentType_MultipleContentTypes_AllFetchSuccessfully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeQueryTest.cs b/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeQueryTest.cs index eb3a629d..0955c235 100644 --- a/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeQueryTest.cs +++ b/Contentstack.Core.Tests/Integration/ContentTypeTests/ContentTypeQueryTest.cs @@ -6,7 +6,7 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit.Abstractions; namespace Contentstack.Core.Tests.Integration.ContentTypeTests @@ -117,7 +117,7 @@ public async Task ContentTypeQuery_SchemaValidation_IsValidJObject() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); } #endregion @@ -360,7 +360,7 @@ await TestAssert.ThrowsAnyAsync(async () => #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/EntryTests/EntryIncludeExtendedTest.cs b/Contentstack.Core.Tests/Integration/EntryTests/EntryIncludeExtendedTest.cs index 9d32523e..e9fbdf30 100644 --- a/Contentstack.Core.Tests/Integration/EntryTests/EntryIncludeExtendedTest.cs +++ b/Contentstack.Core.Tests/Integration/EntryTests/EntryIncludeExtendedTest.cs @@ -390,7 +390,7 @@ public async Task EntryInclude_Performance_MultipleIncludes() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/EntryTests/EntryOperationsComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/EntryTests/EntryOperationsComprehensiveTest.cs index 83732f74..5319b71c 100644 --- a/Contentstack.Core.Tests/Integration/EntryTests/EntryOperationsComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/EntryTests/EntryOperationsComprehensiveTest.cs @@ -734,7 +734,7 @@ public async Task Entry_AddParam_CustomParamIsApplied() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/EntryTests/FieldProjectionAndReferencesTest.cs b/Contentstack.Core.Tests/Integration/EntryTests/FieldProjectionAndReferencesTest.cs index cdd8d5cf..19fb2680 100644 --- a/Contentstack.Core.Tests/Integration/EntryTests/FieldProjectionAndReferencesTest.cs +++ b/Contentstack.Core.Tests/Integration/EntryTests/FieldProjectionAndReferencesTest.cs @@ -7,7 +7,6 @@ using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; using Contentstack.Core.Tests.Models; -using Newtonsoft.Json.Linq; using Xunit.Abstractions; namespace Contentstack.Core.Tests.Integration.EntryTests @@ -619,7 +618,7 @@ public async Task ReferenceQuery_WithOwner_IncludesOwnerInformation() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/GlobalFieldsTests/GlobalFieldsComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/GlobalFieldsTests/GlobalFieldsComprehensiveTest.cs index 1e46fe18..44b18f5e 100644 --- a/Contentstack.Core.Tests/Integration/GlobalFieldsTests/GlobalFieldsComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/GlobalFieldsTests/GlobalFieldsComprehensiveTest.cs @@ -6,7 +6,7 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit.Abstractions; namespace Contentstack.Core.Tests.Integration.GlobalFieldsTests @@ -412,7 +412,7 @@ public async Task GlobalFields_SchemaValidation_IsValidJObject() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); } #endregion @@ -505,7 +505,7 @@ public async Task GlobalFields_EmptyGlobalField_ReturnsValidEntry() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/GlobalFieldsTests/NestedGlobalFieldsTest.cs b/Contentstack.Core.Tests/Integration/GlobalFieldsTests/NestedGlobalFieldsTest.cs index 52b31662..e02728c0 100644 --- a/Contentstack.Core.Tests/Integration/GlobalFieldsTests/NestedGlobalFieldsTest.cs +++ b/Contentstack.Core.Tests/Integration/GlobalFieldsTests/NestedGlobalFieldsTest.cs @@ -6,7 +6,7 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit.Abstractions; namespace Contentstack.Core.Tests.Integration.GlobalFieldsTests @@ -386,7 +386,7 @@ public async Task NestedGlobal_SchemaValidation_IsValidJObject() LogAssert("Verifying response"); TestAssert.NotNull(schema); - TestAssert.IsType(schema); + TestAssert.IsType(schema); } #endregion @@ -425,7 +425,7 @@ public async Task NestedGlobal_Performance_DeepNesting() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/HeaderTests/HeaderManagementTest.cs b/Contentstack.Core.Tests/Integration/HeaderTests/HeaderManagementTest.cs index 741ab4ab..4044c937 100644 --- a/Contentstack.Core.Tests/Integration/HeaderTests/HeaderManagementTest.cs +++ b/Contentstack.Core.Tests/Integration/HeaderTests/HeaderManagementTest.cs @@ -343,7 +343,7 @@ public async Task Header_RequestLevel_IndependentRequests() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ImageDeliveryTests/ImageDeliveryComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/ImageDeliveryTests/ImageDeliveryComprehensiveTest.cs index 5a477e52..17171699 100644 --- a/Contentstack.Core.Tests/Integration/ImageDeliveryTests/ImageDeliveryComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/ImageDeliveryTests/ImageDeliveryComprehensiveTest.cs @@ -311,7 +311,7 @@ public async Task ImageDelivery_Performance_MultipleAssets() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/JSONRTETests/JsonRteEmbeddedItemsTest.cs b/Contentstack.Core.Tests/Integration/JSONRTETests/JsonRteEmbeddedItemsTest.cs index 1b53f515..49af2d50 100644 --- a/Contentstack.Core.Tests/Integration/JSONRTETests/JsonRteEmbeddedItemsTest.cs +++ b/Contentstack.Core.Tests/Integration/JSONRTETests/JsonRteEmbeddedItemsTest.cs @@ -52,7 +52,7 @@ public async Task JsonRte_BasicFetch_ReturnsEntry() var blocks = entry.Get("json_rte_field"); if (blocks != null) { - var blocksArray = blocks as Newtonsoft.Json.Linq.JArray; + var blocksArray = blocks as System.Collections.IList; if (blocksArray != null && blocksArray.Count > 0) { TestAssert.True(blocksArray.Count > 0, "Modular blocks should have content"); @@ -90,7 +90,7 @@ public async Task JsonRte_WithEmbeddedItems_IncludesEmbedded() var blocks = entry.Get("json_rte_field"); if (blocks != null) { - var blocksArray = blocks as Newtonsoft.Json.Linq.JArray; + var blocksArray = blocks as System.Collections.IList; if (blocksArray != null && blocksArray.Count > 0) { TestAssert.True(blocksArray.Count > 0, "Modular blocks should have content"); @@ -132,7 +132,7 @@ public async Task JsonRte_EmbeddedEntry_SingleLevel() var blocks = entry.Get("json_rte_field"); if (blocks != null) { - var blocksArray = blocks as Newtonsoft.Json.Linq.JArray; + var blocksArray = blocks as System.Collections.IList; if (blocksArray != null && blocksArray.Count > 0) { TestAssert.True(blocksArray.Count > 0, "Modular blocks should have content"); @@ -426,7 +426,7 @@ public async Task JsonRte_EmptyRte_HandlesGracefully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/LocalizationTests/LocaleFallbackChainTest.cs b/Contentstack.Core.Tests/Integration/LocalizationTests/LocaleFallbackChainTest.cs index 91e47d55..0ba86c67 100644 --- a/Contentstack.Core.Tests/Integration/LocalizationTests/LocaleFallbackChainTest.cs +++ b/Contentstack.Core.Tests/Integration/LocalizationTests/LocaleFallbackChainTest.cs @@ -399,7 +399,7 @@ public async Task Fallback_NoTranslation_FallsBackCompletely() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/LocalizationTests/LocalizationExtendedTest.cs b/Contentstack.Core.Tests/Integration/LocalizationTests/LocalizationExtendedTest.cs index 19505919..0bc6493d 100644 --- a/Contentstack.Core.Tests/Integration/LocalizationTests/LocalizationExtendedTest.cs +++ b/Contentstack.Core.Tests/Integration/LocalizationTests/LocalizationExtendedTest.cs @@ -257,7 +257,7 @@ public async Task LocaleExtended_MultipleLocaleRequests_Independent() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ModularBlocksTests/ModularBlocksComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/ModularBlocksTests/ModularBlocksComprehensiveTest.cs index cdd275b7..1e553fd0 100644 --- a/Contentstack.Core.Tests/Integration/ModularBlocksTests/ModularBlocksComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/ModularBlocksTests/ModularBlocksComprehensiveTest.cs @@ -52,7 +52,7 @@ public async Task ModularBlocks_BasicFetch_ReturnsEntry() var blocks = entry.Get("modular_blocks"); if (blocks != null) { - var blocksArray = blocks as Newtonsoft.Json.Linq.JArray; + var blocksArray = blocks as System.Collections.IList; if (blocksArray != null && blocksArray.Count > 0) { TestAssert.True(blocksArray.Count > 0, "Modular blocks should have content"); @@ -418,7 +418,7 @@ public async Task ModularBlocks_EmptyBlocks_HandlesGracefully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/PaginationTests/PaginationComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/PaginationTests/PaginationComprehensiveTest.cs index aac04730..201eb26a 100644 --- a/Contentstack.Core.Tests/Integration/PaginationTests/PaginationComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/PaginationTests/PaginationComprehensiveTest.cs @@ -282,7 +282,7 @@ public async Task Pagination_LargeSkip_HandlesGracefully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/PerformanceTests/PerformanceLargeDatasetsTest.cs b/Contentstack.Core.Tests/Integration/PerformanceTests/PerformanceLargeDatasetsTest.cs index 970b47f9..1d501eae 100644 --- a/Contentstack.Core.Tests/Integration/PerformanceTests/PerformanceLargeDatasetsTest.cs +++ b/Contentstack.Core.Tests/Integration/PerformanceTests/PerformanceLargeDatasetsTest.cs @@ -440,7 +440,7 @@ public async Task Performance_CachedVsUncached_Consistency() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryEncodingTests/QueryEncodingComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/QueryEncodingTests/QueryEncodingComprehensiveTest.cs index 58c9ed57..36b6cb4d 100644 --- a/Contentstack.Core.Tests/Integration/QueryEncodingTests/QueryEncodingComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryEncodingTests/QueryEncodingComprehensiveTest.cs @@ -732,7 +732,7 @@ public async Task Encoding_MixedCharacterSet_AllTypesEncoded() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/AdvancedQueryFeaturesTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/AdvancedQueryFeaturesTest.cs index 502607fc..50f40093 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/AdvancedQueryFeaturesTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/AdvancedQueryFeaturesTest.cs @@ -680,7 +680,7 @@ public async Task AdvancedQuery_QueryResultStructure_IsValid() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/ComplexFieldQueriesTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/ComplexFieldQueriesTest.cs index 7a51b4a6..9c6ce8d6 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/ComplexFieldQueriesTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/ComplexFieldQueriesTest.cs @@ -371,7 +371,7 @@ public async Task ComplexField_Performance_DeepNestedQuery() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/ComplexQueryCombinationsTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/ComplexQueryCombinationsTest.cs index c84c8745..de395339 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/ComplexQueryCombinationsTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/ComplexQueryCombinationsTest.cs @@ -337,7 +337,7 @@ public async Task ComplexQuery_Performance_NestedCombinations() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/EntryQueryablesComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/EntryQueryablesComprehensiveTest.cs index e7c88f0a..cba8db06 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/EntryQueryablesComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/EntryQueryablesComprehensiveTest.cs @@ -799,7 +799,7 @@ public async Task Query_Count_ReturnsCorrectCount() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/QueryIncludeExtendedTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/QueryIncludeExtendedTest.cs index 53768c40..fcb5485f 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/QueryIncludeExtendedTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/QueryIncludeExtendedTest.cs @@ -397,7 +397,7 @@ public async Task QueryInclude_Performance_MultipleIncludes() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/QueryTests/QueryOperatorsComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/QueryTests/QueryOperatorsComprehensiveTest.cs index ee10f97e..ed630bc1 100644 --- a/Contentstack.Core.Tests/Integration/QueryTests/QueryOperatorsComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/QueryTests/QueryOperatorsComprehensiveTest.cs @@ -840,7 +840,7 @@ public async Task Query_ChainedOperations_ExecutesInOrder() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ReferenceTests/DeepReferencesComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/ReferenceTests/DeepReferencesComprehensiveTest.cs index 82320426..112ea91f 100644 --- a/Contentstack.Core.Tests/Integration/ReferenceTests/DeepReferencesComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/ReferenceTests/DeepReferencesComprehensiveTest.cs @@ -6,7 +6,6 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Models; using Contentstack.Core.Tests.Helpers; -using Newtonsoft.Json.Linq; using Xunit.Abstractions; namespace Contentstack.Core.Tests.Integration.ReferenceTests @@ -502,7 +501,7 @@ public async Task DeepRef_ReferenceContentTypeUID_IncludesContentTypeInfo() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/ReferenceTests/MultiReferenceTest.cs b/Contentstack.Core.Tests/Integration/ReferenceTests/MultiReferenceTest.cs index 389c40a7..02fc11f6 100644 --- a/Contentstack.Core.Tests/Integration/ReferenceTests/MultiReferenceTest.cs +++ b/Contentstack.Core.Tests/Integration/ReferenceTests/MultiReferenceTest.cs @@ -429,7 +429,7 @@ public async Task MultiRef_EmptyReferenceArray_HandlesGracefully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/RetryTests/RetryIntegrationTest.cs b/Contentstack.Core.Tests/Integration/RetryTests/RetryIntegrationTest.cs index 12d8145d..6ad765eb 100644 --- a/Contentstack.Core.Tests/Integration/RetryTests/RetryIntegrationTest.cs +++ b/Contentstack.Core.Tests/Integration/RetryTests/RetryIntegrationTest.cs @@ -69,13 +69,17 @@ public async Task Retry_MultipleSuccessfulRequests_Consistent() var task3 = client.ContentType(TestDataHelper.ComplexContentTypeUid).Entry(TestDataHelper.ComplexEntryUid).Fetch(); await Task.WhenAll(task1, task2, task3); - + + var entry1 = await task1; + var entry2 = await task2; + var entry3 = await task3; + // Assert LogAssert("Verifying response"); - TestAssert.NotNull(task1.Result); - TestAssert.NotNull(task2.Result); - TestAssert.NotNull(task3.Result); + TestAssert.NotNull(entry1); + TestAssert.NotNull(entry2); + TestAssert.NotNull(entry3); } #endregion @@ -155,19 +159,19 @@ public async Task Retry_ParallelRequests_HandlesLoad() .Fetch()); } - await Task.WhenAll(tasks); - + var entries = await Task.WhenAll(tasks); + // Assert - All should succeed LogAssert("Verifying response"); - TestAssert.True(tasks.All(t => t.Result != null)); + TestAssert.True(entries.All(e => e != null)); } #endregion #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/StackTests/StackOperationsComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/StackTests/StackOperationsComprehensiveTest.cs index 8d0e962c..9908926b 100644 --- a/Contentstack.Core.Tests/Integration/StackTests/StackOperationsComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/StackTests/StackOperationsComprehensiveTest.cs @@ -593,7 +593,7 @@ public async Task Stack_ConcurrentRequests_HandledCorrectly() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/SyncTests/ExtendedSyncApiTest.cs b/Contentstack.Core.Tests/Integration/SyncTests/ExtendedSyncApiTest.cs index bba47f31..2f18f3aa 100644 --- a/Contentstack.Core.Tests/Integration/SyncTests/ExtendedSyncApiTest.cs +++ b/Contentstack.Core.Tests/Integration/SyncTests/ExtendedSyncApiTest.cs @@ -572,7 +572,7 @@ public async Task ExtendedSync_DateBasedFlow_RecentChanges() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/SyncTests/SyncApiComprehensiveTest.cs b/Contentstack.Core.Tests/Integration/SyncTests/SyncApiComprehensiveTest.cs index de7194df..e6a2181c 100644 --- a/Contentstack.Core.Tests/Integration/SyncTests/SyncApiComprehensiveTest.cs +++ b/Contentstack.Core.Tests/Integration/SyncTests/SyncApiComprehensiveTest.cs @@ -402,7 +402,7 @@ await TestAssert.ThrowsAnyAsync(async () => #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomySupportTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomySupportTest.cs index e5846c44..4b65e33e 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomySupportTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomySupportTest.cs @@ -910,7 +910,7 @@ public async Task Taxonomy_WithEmptyOrNullStream_HandlesGracefully() #region Helper Methods - private ContentstackClient CreateClient() + private new ContentstackClient CreateClient() { var options = new ContentstackOptions() { diff --git a/Contentstack.Core.Tests/Mocks/EntryTestHelper.cs b/Contentstack.Core.Tests/Mocks/EntryTestHelper.cs index 41b50c92..4252c6c7 100644 --- a/Contentstack.Core.Tests/Mocks/EntryTestHelper.cs +++ b/Contentstack.Core.Tests/Mocks/EntryTestHelper.cs @@ -7,7 +7,7 @@ using Contentstack.Core.Models; using Contentstack.Core.Tests.Mocks; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Contentstack.Core.Tests.Mocks { @@ -21,8 +21,8 @@ public static class EntryTestHelper /// public static Entry CreateEntryFromJson(string jsonString, ContentstackClient client, string contentTypeId = "source") { - JObject jsonObj = JObject.Parse(jsonString); - JToken entryToken = jsonObj["entry"] ?? jsonObj; + JsonObject jsonObj = JsonNode.Parse(jsonString).AsObject(); + JsonNode entryToken = jsonObj["entry"] ?? jsonObj; // Create Entry using internal constructor var entry = CreateEntryInternal(client, contentTypeId); @@ -30,7 +30,7 @@ public static Entry CreateEntryFromJson(string jsonString, ContentstackClient cl // Use reflection to call ParseObject var parseMethod = typeof(Entry).GetMethod("ParseObject", BindingFlags.NonPublic | BindingFlags.Instance); - parseMethod?.Invoke(entry, new object[] { entryToken as JObject, null }); + parseMethod?.Invoke(entry, new object[] { entryToken.AsObject(), null }); return entry; } diff --git a/Contentstack.Core.Tests/Mocks/MockResponse.cs b/Contentstack.Core.Tests/Mocks/MockResponse.cs index e14d4283..ccef3611 100644 --- a/Contentstack.Core.Tests/Mocks/MockResponse.cs +++ b/Contentstack.Core.Tests/Mocks/MockResponse.cs @@ -1,7 +1,7 @@ using System; using System.IO; using System.Reflection; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Contentstack.Core.Tests.Mocks { @@ -59,24 +59,24 @@ public static string CreateContentstackResponse(string fileName) } /// - /// Creates a JObject from a mock response file + /// Creates a from a mock response file /// /// Name of the JSON file - /// JObject parsed from the response - public static JObject CreateContentstackResponseAsJObject(string fileName) + /// JSON object parsed from the response + public static JsonObject CreateContentstackResponseAsJsonObject(string fileName) { var jsonString = CreateContentstackResponse(fileName); - return JObject.Parse(jsonString); + return JsonNode.Parse(jsonString).AsObject(); } /// - /// Creates a mock response from a JObject + /// Creates a mock response from a /// - /// The JObject to serialize + /// The JSON object to serialize /// JSON string response - public static string CreateContentstackResponseFromJObject(JObject jObject) + public static string CreateContentstackResponseFromJsonObject(JsonObject jsonObject) { - return jObject.ToString(); + return jsonObject.ToJsonString(); } } } diff --git a/Contentstack.Core.Tests/Models/SourceModel.cs b/Contentstack.Core.Tests/Models/SourceModel.cs index cc10c612..4624258a 100644 --- a/Contentstack.Core.Tests/Models/SourceModel.cs +++ b/Contentstack.Core.Tests/Models/SourceModel.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; using Contentstack.Core.Models; using Markdig; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; namespace Contentstack.Core.Tests.Models { @@ -19,8 +20,6 @@ public class SourceModel public Asset file; public List Reference; public List Other_reference; - // public List> Reference; - // public List> Other_reference; public Dictionary Group; public List> Modular_blocks; public object[] Tags; @@ -29,6 +28,7 @@ public class SourceModel [JsonConverter(typeof(CustomDateTimeConverter))] public DateTime updated_at; public string Updated_by; + public String GetHTMLText() { string result = string.Empty; @@ -45,14 +45,25 @@ public String GetHTMLText() } return result; } - } - public class CustomDateTimeConverter : IsoDateTimeConverter + public class CustomDateTimeConverter : JsonConverter { - public CustomDateTimeConverter() + private const string Format = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString(); + if (string.IsNullOrEmpty(s)) + { + return default; + } + return DateTime.ParseExact(s, Format, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { - base.DateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; + writer.WriteStringValue(value.ToUniversalTime().ToString(Format, CultureInfo.InvariantCulture)); } } } diff --git a/Contentstack.Core.Tests/Models/TestPlugin.cs b/Contentstack.Core.Tests/Models/TestPlugin.cs index 3703723a..97cef0c0 100644 --- a/Contentstack.Core.Tests/Models/TestPlugin.cs +++ b/Contentstack.Core.Tests/Models/TestPlugin.cs @@ -2,25 +2,27 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; +using Contentstack.Core.Internals; using Contentstack.Core.Interfaces; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Contentstack.Core.Tests.Models { - public class TestPlugin: IContentstackPlugin + public class TestPlugin : IContentstackPlugin { private ContentstackClient Client; - private JObject injectData; - - private string resp = $"{{\"emails\":[{string.Join(",", new List(){$"\"test\""})}]}}"; + private JsonObject injectData; + + private string resp = $"{{\"emails\":[{string.Join(",", new List() { $"\"test\"" })}]}}"; + public TestPlugin(ContentstackClient client) { Client = client; - injectData = JsonConvert.DeserializeObject(resp.Replace("\r\n", ""), Client.SerializerSettings); + injectData = JsonNode.Parse(resp.Replace("\r\n", "")).AsObject(); } - + public virtual async Task OnRequest(ContentstackClient stack, HttpWebRequest request) { request.Headers["test-header"] = "new header"; @@ -30,49 +32,47 @@ public virtual async Task OnRequest(ContentstackClient stack, Ht public virtual async Task OnResponse(ContentstackClient stack, HttpWebRequest request, HttpWebResponse response, string responseString) { - JObject data = JsonConvert.DeserializeObject(responseString.Replace("\r\n", ""), Client.SerializerSettings); + JsonObject data = JsonNode.Parse(responseString.Replace("\r\n", "")).AsObject(); _ = await Client.AssetLibrary().FetchAll(); updateLivePreviewContent(data); - return JsonConvert.SerializeObject(data); ; + return JsonSerializer.Serialize(data, Client.SerializerOptions); } - internal void updateLivePreviewContent(JObject response) + internal void updateLivePreviewContent(JsonObject response) { if (response.ContainsKey("uid")) { - response.Merge(injectData, new JsonMergeSettings() - { - MergeArrayHandling = MergeArrayHandling.Replace - }); + JsonObjectMerge.UnionMergeInto(response, injectData); + return; } - else + + foreach (var key in response.Select(p => p.Key).ToList()) { - foreach (var content in response) + var node = response[key]; + if (node is JsonArray arr) + { + updateArray(arr); + } + else if (node is JsonObject jo) { - if (content.Value.Type == JTokenType.Array) - { - updateArray((JArray)response[content.Key]); - } - else if (content.Value.Type == JTokenType.Object) - { - updateLivePreviewContent((JObject)response[content.Key]); - } + updateLivePreviewContent(jo); } } } - internal void updateArray(JArray array) + internal void updateArray(JsonArray array) { - for (var i = 0; i < array.Count(); i++) + for (var i = 0; i < array.Count; i++) { - if (array[i].Type == JTokenType.Array) + var item = array[i]; + if (item is JsonArray childArr) { - updateArray((JArray)array[i]); + updateArray(childArr); } - else if (array[i].Type == JTokenType.Object) + else if (item is JsonObject jo) { - updateLivePreviewContent((JObject)array[i]); + updateLivePreviewContent(jo); } } } diff --git a/Contentstack.Core.Tests/StackConfig.cs b/Contentstack.Core.Tests/StackConfig.cs index fe9a39de..68382242 100644 --- a/Contentstack.Core.Tests/StackConfig.cs +++ b/Contentstack.Core.Tests/StackConfig.cs @@ -13,7 +13,13 @@ System.Configuration.Configuration assemblyConfiguration { get { - return ConfigurationManager.OpenExeConfiguration(new Uri(uriString: GetType().Assembly.CodeBase).LocalPath); + var assembly = GetType().Assembly; + var path = assembly.Location; + if (string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException("Assembly.Location is unavailable; cannot load assembly configuration."); + } + return ConfigurationManager.OpenExeConfiguration(path); } } diff --git a/Contentstack.Core.Unit.Tests/AssetLibraryUnitTests.cs b/Contentstack.Core.Unit.Tests/AssetLibraryUnitTests.cs index e819c735..18c590c8 100644 --- a/Contentstack.Core.Unit.Tests/AssetLibraryUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/AssetLibraryUnitTests.cs @@ -7,7 +7,7 @@ using Contentstack.Core.Internals; using Contentstack.Core.Models; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit; namespace Contentstack.Core.Unit.Tests @@ -63,9 +63,9 @@ public void Where_AddsQueryParameter() var urlQueries = (Dictionary)urlQueriesField?.GetValue(assetLibrary); Assert.True(urlQueries?.ContainsKey("query") ?? false); - var query = urlQueries?["query"] as JObject; + var query = urlQueries?["query"] as JsonObject; Assert.NotNull(query); - Assert.Equal(value, query?[key]?.ToString()); + Assert.Equal(value, query[key]?.GetValue()); } [Fact] @@ -115,8 +115,8 @@ public void Where_WithExistingKey_UpdatesQueryParameter() var urlQueriesField = typeof(AssetLibrary).GetField("UrlQueries", BindingFlags.NonPublic | BindingFlags.Instance); var urlQueries = (Dictionary)urlQueriesField?.GetValue(assetLibrary); - var query = urlQueries?["query"] as JObject; - Assert.Equal(value2, query?[key]?.ToString()); + var query = urlQueries?["query"] as JsonObject; + Assert.Equal(value2, query[key]?.GetValue()); } #endregion @@ -622,13 +622,13 @@ public void IncludeCount_AddsQueryParameter() #region Query Factory Method Tests [Fact] - public void Query_WithJObject_AddsQueryToUrlQueries() + public void Query_WithJsonObject_AddsQueryToUrlQueries() { // Arrange var assetLibrary = CreateAssetLibrary(); - var queryObject = new Newtonsoft.Json.Linq.JObject + var queryObject = new JsonObject { - { "field", "value" } + ["field"] = "value" }; // Act diff --git a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj index a4138b9d..cfbb4691 100644 --- a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj +++ b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 false $(Version) @@ -18,12 +18,9 @@ - - - diff --git a/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs b/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs index 44d9cb9a..f5073b8b 100644 --- a/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs @@ -167,9 +167,10 @@ public void GetAccessToken_WithAccessToken_ReturnsAccessToken() var options = new ContentstackOptions() { ApiKey = _fixture.Create(), - AccessToken = accessToken, Environment = _fixture.Create() }; + typeof(ContentstackOptions).GetProperty("AccessToken")! + .SetValue(options, accessToken); var client = new ContentstackClient(new OptionsWrapper(options)); // Act @@ -811,7 +812,7 @@ public void GetHeader_WithLocalHeaderAndNoStackHeaders_ReturnsLocalHeader() } [Fact] - public void LivePreviewQueryAsync_WithContentTypeUid_SetsContentTypeUID() + public async Task LivePreviewQueryAsync_WithContentTypeUid_SetsContentTypeUID() { // Arrange var client = CreateClient(); @@ -821,15 +822,14 @@ public void LivePreviewQueryAsync_WithContentTypeUid_SetsContentTypeUID() }; // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal("test_ct_uid", client.LivePreviewConfig.ContentTypeUID); } [Fact] - public void LivePreviewQueryAsync_WithEntryUid_SetsEntryUID() + public async Task LivePreviewQueryAsync_WithEntryUid_SetsEntryUID() { // Arrange var client = CreateClient(); @@ -839,15 +839,14 @@ public void LivePreviewQueryAsync_WithEntryUid_SetsEntryUID() }; // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal("test_entry_uid", client.LivePreviewConfig.EntryUID); } [Fact] - public void LivePreviewQueryAsync_WithLivePreview_SetsLivePreview() + public async Task LivePreviewQueryAsync_WithLivePreview_SetsLivePreview() { // Arrange var client = CreateClient(); @@ -857,15 +856,14 @@ public void LivePreviewQueryAsync_WithLivePreview_SetsLivePreview() }; // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal("test_hash", client.LivePreviewConfig.LivePreview); } [Fact] - public void LivePreviewQueryAsync_WithReleaseId_SetsReleaseId() + public async Task LivePreviewQueryAsync_WithReleaseId_SetsReleaseId() { // Arrange var client = CreateClient(); @@ -875,15 +873,14 @@ public void LivePreviewQueryAsync_WithReleaseId_SetsReleaseId() }; // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal("test_release_id", client.LivePreviewConfig.ReleaseId); } [Fact] - public void LivePreviewQueryAsync_WithPreviewTimestamp_SetsPreviewTimestamp() + public async Task LivePreviewQueryAsync_WithPreviewTimestamp_SetsPreviewTimestamp() { // Arrange var client = CreateClient(); @@ -893,15 +890,14 @@ public void LivePreviewQueryAsync_WithPreviewTimestamp_SetsPreviewTimestamp() }; // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal("test_timestamp", client.LivePreviewConfig.PreviewTimestamp); } [Fact] - public void LivePreviewQueryAsync_WithoutContentTypeUid_UsesCurrentContentTypeUid() + public async Task LivePreviewQueryAsync_WithoutContentTypeUid_UsesCurrentContentTypeUid() { // Arrange var client = CreateClient(); @@ -913,15 +909,14 @@ public void LivePreviewQueryAsync_WithoutContentTypeUid_UsesCurrentContentTypeUi var query = new Dictionary(); // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal(currentContentTypeUid, client.LivePreviewConfig.ContentTypeUID); } [Fact] - public void LivePreviewQueryAsync_WithoutEntryUid_UsesCurrentEntryUid() + public async Task LivePreviewQueryAsync_WithoutEntryUid_UsesCurrentEntryUid() { // Arrange var client = CreateClient(); @@ -933,15 +928,14 @@ public void LivePreviewQueryAsync_WithoutEntryUid_UsesCurrentEntryUid() var query = new Dictionary(); // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Equal(currentEntryUid, client.LivePreviewConfig.EntryUID); } [Fact] - public void LivePreviewQueryAsync_ClearsLivePreviewConfig() + public async Task LivePreviewQueryAsync_ClearsLivePreviewConfig() { // Arrange var client = CreateClient(); @@ -952,8 +946,7 @@ public void LivePreviewQueryAsync_ClearsLivePreviewConfig() var query = new Dictionary(); // Act - var task = client.LivePreviewQueryAsync(query); - task.Wait(); + await client.LivePreviewQueryAsync(query); // Assert Assert.Null(client.LivePreviewConfig.LivePreview); diff --git a/Contentstack.Core.Unit.Tests/ContentstackOptionsUnitTests.cs b/Contentstack.Core.Unit.Tests/ContentstackOptionsUnitTests.cs index c6e228ad..7c9aee40 100644 --- a/Contentstack.Core.Unit.Tests/ContentstackOptionsUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ContentstackOptionsUnitTests.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Reflection; using AutoFixture; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; @@ -69,15 +70,12 @@ public void AccessToken_SetAndGet_ReturnsCorrectValue() var options = new ContentstackOptions(); var accessToken = _fixture.Create(); - // Act -#pragma warning disable CS0618 // Type or member is obsolete - options.AccessToken = accessToken; -#pragma warning restore CS0618 // Type or member is obsolete + // Act — use reflection so we still validate the deprecated property without CS0618 at compile time + var accessTokenProp = typeof(ContentstackOptions).GetProperty("AccessToken")!; + accessTokenProp.SetValue(options, accessToken); // Assert -#pragma warning disable CS0618 // Type or member is obsolete - Assert.Equal(accessToken, options.AccessToken); -#pragma warning restore CS0618 // Type or member is obsolete + Assert.Equal(accessToken, accessTokenProp.GetValue(options)); } [Fact] diff --git a/Contentstack.Core.Unit.Tests/EntryUnitTests.cs b/Contentstack.Core.Unit.Tests/EntryUnitTests.cs index 859cd063..37b11862 100644 --- a/Contentstack.Core.Unit.Tests/EntryUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/EntryUnitTests.cs @@ -10,7 +10,7 @@ using Contentstack.Core.Models; using Contentstack.Core.Unit.Tests.Mokes; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit; namespace Contentstack.Core.Unit.Tests @@ -838,31 +838,31 @@ public void GetMetadata_WithoutMetadata_ReturnsNull() #region ToJson Tests [Fact] - public void ToJson_ReturnsJObject() + public void ToJson_ReturnsJsonObject() { // Arrange var entry = CreateEntry(); var jObjectField = typeof(Entry).GetField("jObject", BindingFlags.NonPublic | BindingFlags.Instance); - var jObject = new JObject + var jsonObject = new JsonObject { - { "uid", "test_entry_uid" }, - { "title", "Test Entry" }, - { "content_type_uid", "source" } + ["uid"] = "test_entry_uid", + ["title"] = "Test Entry", + ["content_type_uid"] = "source" }; - jObjectField?.SetValue(entry, jObject); + jObjectField?.SetValue(entry, jsonObject); // Act - JObject result = entry.ToJson(); + JsonObject result = entry.ToJson(); // Assert Assert.NotNull(result); - Assert.Equal("test_entry_uid", result["uid"].ToString()); - Assert.Equal("Test Entry", result["title"].ToString()); + Assert.Equal("test_entry_uid", result["uid"]?.GetValue()); + Assert.Equal("Test Entry", result["title"]?.GetValue()); } [Fact] - public void ToJson_WithNullJObject_ReturnsNull() + public void ToJson_WithNullJsonObject_ReturnsNull() { // Arrange var entry = CreateEntry(); @@ -871,7 +871,7 @@ public void ToJson_WithNullJObject_ReturnsNull() jObjectField?.SetValue(entry, null); // Act - JObject result = entry.ToJson(); + JsonObject result = entry.ToJson(); // Assert Assert.Null(result); @@ -1915,13 +1915,13 @@ public void ParseObject_WithMetadata_ParsesMetadata() var entry = CreateEntry(); var parseObjectMethod = typeof(Entry).GetMethod("ParseObject", BindingFlags.NonPublic | BindingFlags.Instance); - var json = new JObject + var json = new JsonObject { ["title"] = "Test Title", - ["_metadata"] = new JObject + ["_metadata"] = new JsonObject { ["uid"] = "test_uid", - ["tags"] = new JArray("tag1", "tag2") + ["tags"] = new JsonArray("tag1", "tag2") } }; @@ -1942,7 +1942,7 @@ public void ParseObject_WithoutMetadata_DoesNotSetMetadata() var entry = CreateEntry(); var parseObjectMethod = typeof(Entry).GetMethod("ParseObject", BindingFlags.NonPublic | BindingFlags.Instance); - var json = new JObject + var json = new JsonObject { ["title"] = "Test Title" }; diff --git a/Contentstack.Core.Unit.Tests/JsonConverterUnitTests.cs b/Contentstack.Core.Unit.Tests/JsonConverterUnitTests.cs index c5036510..bb9f1beb 100644 --- a/Contentstack.Core.Unit.Tests/JsonConverterUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/JsonConverterUnitTests.cs @@ -1,13 +1,13 @@ using System; -using System.IO; -using System.Reflection; +using System.Buffers; +using System.Text; +using System.Text.Json; using AutoFixture; using Contentstack.Core; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; using Contentstack.Core.Models; using Microsoft.Extensions.Options; -using Newtonsoft.Json; using Xunit; namespace Contentstack.Core.Unit.Tests @@ -28,36 +28,33 @@ private ContentstackClient CreateClient() } [Fact] - public void ReadJson_WithValidJson_ReturnsEntry() + public void Read_WithValidJson_ReturnsEntry() { - // Arrange var converter = new EntryJsonConverter(); var json = "{\"uid\":\"test_uid\",\"title\":\"Test Title\"}"; - var reader = new JsonTextReader(new StringReader(json)); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); - // Act - var entry = converter.ReadJson(reader, typeof(Entry), null, JsonSerializer.CreateDefault()); + var entry = converter.Read(ref reader, typeof(Entry), CreateClient().SerializerOptions); - // Assert Assert.NotNull(entry); Assert.IsType(entry); } [Fact] - public void WriteJson_WithEntry_DoesNothing() + public void Write_WithEntry_Completes() { - // Arrange var converter = new EntryJsonConverter(); var client = CreateClient(); var contentType = client.ContentType("test_content_type"); var entry = contentType.Entry("test_uid"); - var writer = new JsonTextWriter(new StringWriter()); - - // Act - Should not throw - converter.WriteJson(writer, entry, JsonSerializer.CreateDefault()); + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + converter.Write(writer, entry, client.SerializerOptions); + } - // Assert - Assert.True(true); + Assert.True(buffer.WrittenCount > 0); } } @@ -77,34 +74,34 @@ private ContentstackClient CreateClient() } [Fact] - public void ReadJson_WithValidJson_ReturnsAsset() + public void Read_WithValidJson_ReturnsAsset() { - // Arrange var converter = new AssetJsonConverter(); var json = "{\"uid\":\"test_uid\",\"title\":\"Test Asset\"}"; - var reader = new JsonTextReader(new StringReader(json)); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); - // Act - var asset = converter.ReadJson(reader, typeof(Asset), null, JsonSerializer.CreateDefault()); + var asset = converter.Read(ref reader, typeof(Asset), CreateClient().SerializerOptions); - // Assert Assert.NotNull(asset); Assert.IsType(asset); } [Fact] - public void WriteJson_WithAsset_ThrowsAssetException() + public void Write_WithAsset_ThrowsAssetException() { - // Arrange var converter = new AssetJsonConverter(); var client = CreateClient(); var asset = client.Asset("test_uid"); - var writer = new JsonTextWriter(new StringWriter()); + var buffer = new ArrayBufferWriter(); - // Act & Assert - Assert.Throws(() => - converter.WriteJson(writer, asset, JsonSerializer.CreateDefault())); + Assert.Throws(() => + { + using (var writer = new Utf8JsonWriter(buffer)) + { + converter.Write(writer, asset, client.SerializerOptions); + } + }); } } } - diff --git a/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs b/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs index 37e22d76..cef349fb 100644 --- a/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs @@ -1,7 +1,6 @@ using System; using AutoFixture; using Contentstack.Core.Configuration; -using Newtonsoft.Json.Linq; using Xunit; namespace Contentstack.Core.Unit.Tests diff --git a/Contentstack.Core.Unit.Tests/Mokes/MockHttpHandler.cs b/Contentstack.Core.Unit.Tests/Mokes/MockHttpHandler.cs index e1d50e38..57193312 100644 --- a/Contentstack.Core.Unit.Tests/Mokes/MockHttpHandler.cs +++ b/Contentstack.Core.Unit.Tests/Mokes/MockHttpHandler.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using Contentstack.Core.Interfaces; +using System.Text.Json.Nodes; namespace Contentstack.Core.Unit.Tests.Mokes { @@ -80,9 +81,9 @@ public string OpenResponse() return _response; } - public Newtonsoft.Json.Linq.JObject OpenJObjectResponse() + public JsonObject OpenJsonObjectResponse() { - return Newtonsoft.Json.Linq.JObject.Parse(_response ?? "{}"); + return JsonNode.Parse(_response ?? "{}").AsObject(); } } } diff --git a/Contentstack.Core.Unit.Tests/Mokes/MockInfrastructureTest.cs b/Contentstack.Core.Unit.Tests/Mokes/MockInfrastructureTest.cs index 263dd7da..54e47a16 100644 --- a/Contentstack.Core.Unit.Tests/Mokes/MockInfrastructureTest.cs +++ b/Contentstack.Core.Unit.Tests/Mokes/MockInfrastructureTest.cs @@ -27,13 +27,13 @@ public void MockResponse_CreateContentstackResponse_ShouldLoadFromResource() } [Fact] - public void MockResponse_CreateContentstackResponseAsJObject_ShouldParseJson() + public void MockResponse_CreateContentstackResponseAsJsonObject_ShouldParseJson() { // Arrange & Act - var jObject = MockResponse.CreateContentstackResponseAsJObject("MockResponse.txt"); + var jsonObject = MockResponse.CreateContentstackResponseAsJsonObject("MockResponse.txt"); // Assert - Assert.NotNull(jObject); + Assert.NotNull(jsonObject); } [Fact] @@ -51,18 +51,18 @@ public void ContentstackResponse_OpenResponse_ShouldReturnResponseString() } [Fact] - public void ContentstackResponse_OpenJObjectResponse_ShouldParseJson() + public void ContentstackResponse_OpenJsonObjectResponse_ShouldParseJson() { // Arrange var responseString = "{\"test\": \"value\"}"; var response = new ContentstackResponse(responseString); // Act - var jObject = response.OpenJObjectResponse(); + var jsonObject = response.OpenJsonObjectResponse(); // Assert - Assert.NotNull(jObject); - Assert.Equal("value", jObject["test"]?.ToString()); + Assert.NotNull(jsonObject); + Assert.Equal("value", jsonObject["test"]?.GetValue()); } [Fact] diff --git a/Contentstack.Core.Unit.Tests/Mokes/MockResponse.cs b/Contentstack.Core.Unit.Tests/Mokes/MockResponse.cs index ce2f3517..967ed2eb 100644 --- a/Contentstack.Core.Unit.Tests/Mokes/MockResponse.cs +++ b/Contentstack.Core.Unit.Tests/Mokes/MockResponse.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Contentstack.Core.Unit.Tests.Mokes; namespace Contentstack.Core.Unit.Tests.Mokes @@ -58,14 +58,14 @@ public static string CreateContentstackResponse(string resourceName) } /// - /// Creates a JObject from a mock response file + /// Creates a from a mock response file /// /// Name of the resource file - /// JObject parsed from the response - public static JObject CreateContentstackResponseAsJObject(string resourceName) + /// JSON object parsed from the response + public static JsonObject CreateContentstackResponseAsJsonObject(string resourceName) { var jsonString = CreateContentstackResponse(resourceName); - return JObject.Parse(jsonString); + return JsonNode.Parse(jsonString).AsObject(); } /// diff --git a/Contentstack.Core.Unit.Tests/SyncStackUnitTests.cs b/Contentstack.Core.Unit.Tests/SyncStackUnitTests.cs index 888902d8..f84bb099 100644 --- a/Contentstack.Core.Unit.Tests/SyncStackUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/SyncStackUnitTests.cs @@ -282,7 +282,7 @@ public void SyncStack_Items_CanBeIterated() #endregion - #region JSON Serialization Tests (Based on JsonProperty attributes) + #region JSON Serialization Tests (Based on JsonPropertyName attributes) [Fact] public void SyncStack_TotalCount_ShouldMapToTotalCountProperty() @@ -294,7 +294,7 @@ public void SyncStack_TotalCount_ShouldMapToTotalCountProperty() }; // Act & Assert - // The property uses [JsonProperty("total_count")] so it should serialize correctly + // The property uses [JsonPropertyName("total_count")] so it should serialize correctly Assert.Equal(42, syncStack.TotalCount); } @@ -308,7 +308,7 @@ public void SyncStack_SyncToken_ShouldMapToSyncTokenProperty() }; // Act & Assert - // The property uses [JsonProperty("sync_token")] so it should serialize correctly + // The property uses [JsonPropertyName("sync_token")] so it should serialize correctly Assert.Equal("test_token", syncStack.SyncToken); } @@ -322,7 +322,7 @@ public void SyncStack_PaginationToken_ShouldMapToPaginationTokenProperty() }; // Act & Assert - // The property uses [JsonProperty("pagination_token")] so it should serialize correctly + // The property uses [JsonPropertyName("pagination_token")] so it should serialize correctly Assert.Equal("test_pagination_token", syncStack.PaginationToken); } diff --git a/Contentstack.Core.Unit.Tests/WebRequestAsyncExtensionsUnitTests.cs b/Contentstack.Core.Unit.Tests/WebRequestAsyncExtensionsUnitTests.cs index 4fedb9f6..737355b8 100644 --- a/Contentstack.Core.Unit.Tests/WebRequestAsyncExtensionsUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/WebRequestAsyncExtensionsUnitTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Net; +using System.Reflection; using System.Threading.Tasks; using Contentstack.Core.Internals; using Xunit; @@ -9,11 +10,20 @@ namespace Contentstack.Core.Unit.Tests { public class WebRequestAsyncExtensionsUnitTests { + /// + /// Avoids SYSLIB0014 on the test assembly while still constructing the concrete request type the production extensions target. + /// + private static WebRequest CreateHttpWebRequest(string url) + { + var createHttp = typeof(WebRequest).GetMethod("CreateHttp", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null); + return (WebRequest)createHttp!.Invoke(null, new object[] { url })!; + } + [Fact] public void GetRequestStreamAsync_WithHttpWebRequest_ReturnsTask() { // Arrange - var request = (HttpWebRequest)WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); request.Method = "POST"; // Act @@ -28,7 +38,7 @@ public void GetRequestStreamAsync_WithHttpWebRequest_ReturnsTask() public void GetResponseAsync_WithHttpWebRequest_ReturnsTask() { // Arrange - var request = (HttpWebRequest)WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); // Act var task = request.GetResponseAsync(); @@ -42,7 +52,7 @@ public void GetResponseAsync_WithHttpWebRequest_ReturnsTask() public async Task GetRequestStreamAsync_ExecutesAsyncOperation() { // Arrange - var request = (HttpWebRequest)WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); request.Method = "POST"; request.Timeout = 1000; // Short timeout to fail fast @@ -53,7 +63,7 @@ public async Task GetRequestStreamAsync_ExecutesAsyncOperation() // Attempt to await - this will execute the async code path // We expect it to fail (network error), but that's ok - we just want coverage // Note: Record.ExceptionAsync can return null if exception is swallowed or handled - var exception = await Record.ExceptionAsync(async () => await task); + _ = await Record.ExceptionAsync(async () => await task); // The task may fail or succeed, but we've executed the code path for coverage Assert.NotNull(task); // Task should be created } @@ -62,7 +72,7 @@ public async Task GetRequestStreamAsync_ExecutesAsyncOperation() public async Task GetResponseAsync_ExecutesAsyncOperation() { // Arrange - var request = (HttpWebRequest)WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); request.Timeout = 1000; // Short timeout to fail fast // Act - The extension method should execute @@ -72,7 +82,7 @@ public async Task GetResponseAsync_ExecutesAsyncOperation() // Attempt to await - this will execute the async code path // We expect it to fail (network error), but that's ok - we just want coverage // Note: Record.ExceptionAsync can return null if exception is swallowed or handled - var exception = await Record.ExceptionAsync(async () => await task); + _ = await Record.ExceptionAsync(async () => await task); // The task may fail or succeed, but we've executed the code path for coverage Assert.NotNull(task); // Task should be created } @@ -81,7 +91,7 @@ public async Task GetResponseAsync_ExecutesAsyncOperation() public void GetRequestStreamAsync_WithWebRequest_ReturnsTask() { // Arrange - var request = WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); request.Method = "POST"; // Act @@ -96,7 +106,7 @@ public void GetRequestStreamAsync_WithWebRequest_ReturnsTask() public void GetResponseAsync_WithWebRequest_ReturnsTask() { // Arrange - var request = WebRequest.Create("http://example.com"); + var request = CreateHttpWebRequest("http://example.com"); // Act var task = request.GetResponseAsync(); diff --git a/Contentstack.Core/Attributes/CSJsonConverterAttribute.cs b/Contentstack.Core/Attributes/CSJsonConverterAttribute.cs index 4020c7ec..d361734e 100644 --- a/Contentstack.Core/Attributes/CSJsonConverterAttribute.cs +++ b/Contentstack.Core/Attributes/CSJsonConverterAttribute.cs @@ -69,9 +69,8 @@ internal static IEnumerable GetCustomAttribute(Type attribute) } } } - catch (Exception ex) + catch { - } } _types[attribute] = result; diff --git a/Contentstack.Core/Configuration/ContentstackOptions.cs b/Contentstack.Core/Configuration/ContentstackOptions.cs index 62c1cf59..9c85a25f 100644 --- a/Contentstack.Core/Configuration/ContentstackOptions.cs +++ b/Contentstack.Core/Configuration/ContentstackOptions.cs @@ -75,6 +75,27 @@ public ContentstackOptions() Timeout = 30000; // Set default value } + /// + /// Resolves the token for the access_token HTTP header. Obsolete AccessToken is still honored when set (before DeliveryToken). + /// + internal bool TryGetAccessTokenHeaderValue(out string token) + { +#pragma warning disable CS0618 // AccessToken is obsolete; retained for backward compatibility until removed from the API surface + if (AccessToken != null) + { + token = AccessToken; + return true; + } +#pragma warning restore CS0618 + if (DeliveryToken != null) + { + token = DeliveryToken; + return true; + } + token = null; + return false; + } + /// /// TheEarlyAccessHeader used to set service which the user has early access to. /// diff --git a/Contentstack.Core/Configuration/LivePreviewConfig.cs b/Contentstack.Core/Configuration/LivePreviewConfig.cs index 18670ca5..92d6c827 100644 --- a/Contentstack.Core/Configuration/LivePreviewConfig.cs +++ b/Contentstack.Core/Configuration/LivePreviewConfig.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Contentstack.Core.Configuration { @@ -11,7 +11,7 @@ public class LivePreviewConfig internal string LivePreview { get; set; } internal string ContentTypeUID { get; set; } internal string EntryUID { get; set; } - internal JObject PreviewResponse { get; set; } + internal JsonObject PreviewResponse { get; set; } public string ReleaseId {get; set;} public string PreviewTimestamp {get; set;} } diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index d9b55f29..7b05f32a 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -2,6 +2,7 @@ netstandard2.0;net47;net472; + latest contentstack.csharp Contentstack .NET SDK for the Contentstack Content Delivery API. @@ -28,7 +29,7 @@ - + diff --git a/Contentstack.Core/ContentstackClient.cs b/Contentstack.Core/ContentstackClient.cs index 85ebccdb..caf7acbb 100644 --- a/Contentstack.Core/ContentstackClient.cs +++ b/Contentstack.Core/ContentstackClient.cs @@ -4,14 +4,15 @@ using Contentstack.Core.Configuration; using Microsoft.Extensions.Options; using Contentstack.Core.Models; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json; using System.Linq; using System.Threading.Tasks; using System.Net; using System.IO; using System.Collections; using Contentstack.Core.Interfaces; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Contentstack.Core { @@ -21,9 +22,9 @@ namespace Contentstack.Core public class ContentstackClient { /// - /// Gets or sets the settings that should be used for deserialization. + /// Gets or sets the options used for JSON serialization and deserialization (System.Text.Json). /// - public JsonSerializerSettings SerializerSettings { get; set; } = new JsonSerializerSettings(); + public JsonSerializerOptions SerializerOptions { get; set; } = new JsonSerializerOptions(); #region Internal Variables @@ -34,7 +35,6 @@ internal string StackApiKey } private ContentstackOptions _options; - internal JsonSerializer Serializer => JsonSerializer.Create(SerializerSettings); internal string _SyncUrl { get @@ -83,13 +83,9 @@ public ContentstackClient(IOptions options) this.StackApiKey = _options.ApiKey; this._LocalHeaders = new Dictionary(); this.SetHeader("api_key", _options.ApiKey); - if (_options.AccessToken != null) + if (_options.TryGetAccessTokenHeaderValue(out var authToken)) { - this.SetHeader("access_token", _options.AccessToken); - } - else if (_options.DeliveryToken != null) - { - this.SetHeader("access_token", _options.DeliveryToken); + this.SetHeader("access_token", authToken); } if (_options.EarlyAccessHeader != null) { @@ -139,14 +135,18 @@ public ContentstackClient(IOptions options) throw new InvalidOperationException(ErrorMessages.LivePreviewTokenMissing); } } - this.SerializerSettings.DateParseHandling = DateParseHandling.None; - this.SerializerSettings.DateFormatHandling = DateFormatHandling.IsoDateFormat; - this.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc; - this.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + this.SerializerOptions.PropertyNameCaseInsensitive = true; + this.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + this.SerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip; + this.SerializerOptions.AllowTrailingCommas = true; + this.SerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; foreach (Type t in CSJsonConverterAttribute.GetCustomAttribute(typeof(CSJsonConverterAttribute))) { - SerializerSettings.Converters.Add((JsonConverter)Activator.CreateInstance(t)); + if (Activator.CreateInstance(t) is JsonConverter converter) + { + SerializerOptions.Converters.Add(converter); + } } } @@ -215,19 +215,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -331,8 +319,16 @@ public async Task GetContentTypes(Dictionary param = null { HttpRequestHandler RequestHandler = new HttpRequestHandler(this); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson, Branch: this.Config.Branch, timeout: this.Config.Timeout, proxy: this.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - IList contentTypes = (IList)data["content_types"]; + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); + var ctArray = data["content_types"]?.AsArray(); + var contentTypes = new List(); + if (ctArray != null) + { + foreach (var item in ctArray) + { + contentTypes.Add(JsonNodeConversion.JsonNodeToClr(item)); + } + } return contentTypes; } catch (Exception ex) @@ -341,7 +337,7 @@ public async Task GetContentTypes(Dictionary param = null } } - private async Task GetLivePreviewData() + private async Task GetLivePreviewData() { Dictionary headerAll = new Dictionary(); @@ -389,8 +385,8 @@ private async Task GetLivePreviewData() //string branch = this.Config.Branch ? this.Config.Branch : "main"; string URL = String.Format("{0}/content_types/{1}/entries/{2}", this.Config.getBaseUrl(this.LivePreviewConfig, this.LivePreviewConfig.ContentTypeUID), this.LivePreviewConfig.ContentTypeUID, this.LivePreviewConfig.EntryUID); var outputResult = await RequestHandler.ProcessRequest(URL, headerAll, mainJson, Branch: this.Config.Branch, isLivePreview: true, timeout: this.Config.Timeout, proxy: this.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - return (JObject)data["entry"]; + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); + return data["entry"]!.AsObject(); } catch (Exception ex) { @@ -867,7 +863,7 @@ private async Task GetResultAsync(string Init = "false", SyncType Syn { HttpRequestHandler requestHandler = new HttpRequestHandler(this); string js = await requestHandler.ProcessRequest(_SyncUrl, _LocalHeaders, mainJson, Branch: this.Config.Branch, timeout: this.Config.Timeout, proxy: this.Config.Proxy); - SyncStack stackSyncOutput = JsonConvert.DeserializeObject(js); + SyncStack stackSyncOutput = JsonSerializer.Deserialize(js, SerializerOptions); return stackSyncOutput; } catch (Exception ex) diff --git a/Contentstack.Core/Internals/ApiErrorBodyParser.cs b/Contentstack.Core/Internals/ApiErrorBodyParser.cs new file mode 100644 index 00000000..6a4e6f05 --- /dev/null +++ b/Contentstack.Core/Internals/ApiErrorBodyParser.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Contentstack.Core.Internals +{ + /// + /// Parses JSON error bodies from the Contentstack API (error_code, error_message, errors). + /// + internal static class ApiErrorBodyParser + { + internal static void TryApply( + string body, + ref int errorCode, + ref string errorMessage, + ref Dictionary errors) + { + if (string.IsNullOrWhiteSpace(body)) + { + return; + } + + try + { + var node = JsonNode.Parse(body.Replace("\r\n", "")); + if (node is not JsonObject data) + { + return; + } + + if (data["error_code"] is JsonValue ec) + { + try + { + errorCode = ec.GetValue(); + } + catch + { + if (int.TryParse(ec.ToString(), out var p)) + { + errorCode = p; + } + } + } + + if (data["error_message"] is JsonValue em) + { + var s = em.GetValue(); + if (s != null) + { + errorMessage = s; + } + } + + if (data["errors"] is JsonObject errObj) + { + errors = JsonNodeConversion.JsonObjectToDictionary(errObj); + } + else if (data["errors"] is JsonArray) + { + errors = new Dictionary + { + { "errors", JsonNodeConversion.JsonNodeToClr(data["errors"]) } + }; + } + } + catch (JsonException) + { + } + } + } +} diff --git a/Contentstack.Core/Internals/AssetJsonConverter.cs b/Contentstack.Core/Internals/AssetJsonConverter.cs index 677682d1..dc009ba2 100644 --- a/Contentstack.Core/Internals/AssetJsonConverter.cs +++ b/Contentstack.Core/Internals/AssetJsonConverter.cs @@ -1,24 +1,29 @@ using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Contentstack.Core.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Contentstack.Core.Internals { [CSJsonConverter("AssetJsonConverter")] public class AssetJsonConverter : JsonConverter { - public override Asset ReadJson(JsonReader reader, Type objectType, Asset existingValue, bool hasExistingValue, JsonSerializer serializer) + public override Asset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - JObject jObject = JObject.Load(reader); - JsonSerializerSettings SerializerSettings = new JsonSerializerSettings(); - JsonSerializer Serializer = JsonSerializer.Create(SerializerSettings); - Asset asset = jObject.ToObject(Serializer); - asset.ParseObject(jObject); + using var doc = JsonDocument.ParseValue(ref reader); + var jsonObject = JsonNode.Parse(doc.RootElement.GetRawText())!.AsObject(); + + var asset = JsonSerializer.Deserialize( + jsonObject.ToJsonString(), + ContentstackJsonDefaults.ModelDeserializeOnly); + + asset ??= new Asset(); + asset.ParseObject(jsonObject); return asset; } - public override void WriteJson(JsonWriter writer, Asset value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Asset value, JsonSerializerOptions options) { throw AssetException.CreateForJsonConversionError(); } diff --git a/Contentstack.Core/Internals/ContentstackConvert.cs b/Contentstack.Core/Internals/ContentstackConvert.cs index 369bc32b..57f7e2a0 100644 --- a/Contentstack.Core/Internals/ContentstackConvert.cs +++ b/Contentstack.Core/Internals/ContentstackConvert.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Text.RegularExpressions; @@ -19,7 +20,7 @@ public static Int32 ToInt32(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -38,7 +39,7 @@ public static bool ToBoolean(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -57,7 +58,7 @@ public static string ToString(object input, string defaultValue = "") { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -76,7 +77,7 @@ public static double ToDouble(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -95,7 +96,7 @@ public static decimal ToDecimal(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -114,7 +115,7 @@ public static DateTime ToDateTime(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } @@ -132,7 +133,7 @@ public static string ToISODate(object input) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } return now.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'sszzz"); @@ -186,7 +187,7 @@ public static object GetValue(string value) { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } diff --git a/Contentstack.Core/Internals/ContentstackJsonDefaults.cs b/Contentstack.Core/Internals/ContentstackJsonDefaults.cs new file mode 100644 index 00000000..2e91391b --- /dev/null +++ b/Contentstack.Core/Internals/ContentstackJsonDefaults.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Contentstack.Core.Internals +{ + /// + /// Options used inside custom converters when deserializing POCO properties without re-entering SDK converters (matches former Newtonsoft empty usage). + /// + internal static class ContentstackJsonDefaults + { + internal static readonly JsonSerializerOptions ModelDeserializeOnly = CreateModelDeserializeOnly(); + + private static JsonSerializerOptions CreateModelDeserializeOnly() + { + var o = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + return o; + } + } +} diff --git a/Contentstack.Core/Internals/EntryJsonConverter.cs b/Contentstack.Core/Internals/EntryJsonConverter.cs index 230e1b86..403f2da0 100644 --- a/Contentstack.Core/Internals/EntryJsonConverter.cs +++ b/Contentstack.Core/Internals/EntryJsonConverter.cs @@ -1,26 +1,33 @@ using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Contentstack.Core.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Contentstack.Core.Internals { [CSJsonConverter("EntryJsonConverter")] public class EntryJsonConverter : JsonConverter { - public override Entry ReadJson(JsonReader reader, Type objectType, Entry existingValue, bool hasExistingValue, JsonSerializer serializer) + public override Entry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - JObject jObject = JObject.Load(reader); - JsonSerializerSettings SerializerSettings = new JsonSerializerSettings(); - JsonSerializer Serializer = JsonSerializer.Create(SerializerSettings); - Entry entry = jObject.ToObject(Serializer); - entry.ParseObject(jObject); + using var doc = JsonDocument.ParseValue(ref reader); + var jsonObject = JsonNode.Parse(doc.RootElement.GetRawText())!.AsObject(); + + var entry = JsonSerializer.Deserialize( + jsonObject.ToJsonString(), + ContentstackJsonDefaults.ModelDeserializeOnly); + + entry ??= new Entry(); + entry.ParseObject(jsonObject); return entry; } - public override void WriteJson(JsonWriter writer, Entry value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Entry value, JsonSerializerOptions options) { - + // Serialization of Entry is handled outside this converter during normal flows. + writer.WriteStartObject(); + writer.WriteEndObject(); } } } diff --git a/Contentstack.Core/Internals/HttpRequestHandler.cs b/Contentstack.Core/Internals/HttpRequestHandler.cs index 8d367497..1a20deb9 100644 --- a/Contentstack.Core/Internals/HttpRequestHandler.cs +++ b/Contentstack.Core/Internals/HttpRequestHandler.cs @@ -5,8 +5,7 @@ using System.Net; using System.Text; using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; namespace Contentstack.Core.Internals { @@ -48,7 +47,7 @@ public async Task ProcessRequest( return value; } else if (kvp.Value is Dictionary) - value = JsonConvert.SerializeObject(kvp.Value); + value = JsonSerializer.Serialize(kvp.Value, client.SerializerOptions); else return String.Format("{0}={1}", kvp.Key, kvp.Value); @@ -91,7 +90,7 @@ public async Task ProcessRequest( } ; - var serializedresult = JsonConvert.SerializeObject(BodyJson); + var serializedresult = JsonSerializer.Serialize(BodyJson, client.SerializerOptions); byte[] requestBody = Encoding.UTF8.GetBytes(serializedresult); StreamReader reader = null; HttpWebResponse response = null; diff --git a/Contentstack.Core/Internals/JsonNodeConversion.cs b/Contentstack.Core/Internals/JsonNodeConversion.cs new file mode 100644 index 00000000..97bd7f4a --- /dev/null +++ b/Contentstack.Core/Internals/JsonNodeConversion.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Contentstack.Core.Internals +{ + /// + /// Converts trees to CLR types similar to Newtonsoft + /// ToObject<Dictionary<string, object>> (nested objects and collections). + /// + internal static class JsonNodeConversion + { + internal static Dictionary JsonObjectToDictionary(JsonObject obj) + { + var dict = new Dictionary(StringComparer.Ordinal); + if (obj == null) + { + return dict; + } + + foreach (var kvp in obj) + { + dict[kvp.Key] = JsonNodeToClr(kvp.Value); + } + + return dict; + } + + internal static object JsonNodeToClr(JsonNode node) + { + if (node == null) + { + return null; + } + + if (node is JsonObject jo) + { + return JsonObjectToDictionary(jo); + } + + if (node is JsonArray ja) + { + var list = new List(ja.Count); + foreach (var item in ja) + { + list.Add(JsonNodeToClr(item)); + } + return list; + } + + if (node is JsonValue jv) + { + return JsonValueToClr(jv); + } + + return null; + } + + private static object JsonValueToClr(JsonValue jv) + { + var el = JsonSerializer.Deserialize(jv.ToJsonString()); + return JsonElementToObject(el); + } + + private static object JsonElementToObject(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + var o = new Dictionary(StringComparer.Ordinal); + foreach (var p in el.EnumerateObject()) + { + o[p.Name] = JsonElementToObject(p.Value); + } + return o; + case JsonValueKind.Array: + var list = new List(); + foreach (var item in el.EnumerateArray()) + { + list.Add(JsonElementToObject(item)); + } + return list; + case JsonValueKind.String: + return el.GetString(); + case JsonValueKind.Number: + if (el.TryGetInt32(out var i32)) + { + return i32; + } + if (el.TryGetInt64(out var i64)) + { + return i64; + } + if (el.TryGetDouble(out var d)) + { + return d; + } + return el.GetRawText(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + default: + return null; + } + } + } +} diff --git a/Contentstack.Core/Internals/JsonObjectMerge.cs b/Contentstack.Core/Internals/JsonObjectMerge.cs new file mode 100644 index 00000000..ab4487e5 --- /dev/null +++ b/Contentstack.Core/Internals/JsonObjectMerge.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Contentstack.Core.Internals +{ + /// + /// Replaces Newtonsoft JObject.Merge for query objects in . + /// + internal static class JsonObjectMerge + { + /// + /// Merges into (modifies ). + /// For array values, appends items from that are not already present + /// (string compare of JSON text), similar to Newtonsoft MergeArrayHandling.Union. + /// + internal static void UnionMergeInto(JsonObject target, JsonObject addition) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + if (addition == null) + { + return; + } + + foreach (var kvp in addition) + { + if (!target.TryGetPropertyValue(kvp.Key, out var existing) || existing == null) + { + target[kvp.Key] = kvp.Value?.DeepClone(); + continue; + } + + if (existing is JsonObject existingObj && kvp.Value is JsonObject addObj) + { + UnionMergeInto(existingObj, addObj); + } + else if (existing is JsonArray existingArr && kvp.Value is JsonArray addArr) + { + var present = new HashSet(StringComparer.Ordinal); + foreach (var n in existingArr) + { + if (n != null) + { + present.Add(n.ToJsonString()); + } + } + foreach (var n in addArr) + { + if (n == null) + { + continue; + } + var s = n.ToJsonString(); + if (present.Add(s)) + { + existingArr.Add(n.DeepClone()); + } + } + } + else + { + target[kvp.Key] = kvp.Value?.DeepClone(); + } + } + } + } +} diff --git a/Contentstack.Core/Internals/StackOutput.cs b/Contentstack.Core/Internals/StackOutput.cs index 235acc11..493aeddd 100644 --- a/Contentstack.Core/Internals/StackOutput.cs +++ b/Contentstack.Core/Internals/StackOutput.cs @@ -6,6 +6,24 @@ namespace Contentstack.Core.Internals { internal class StackOutput { + internal StackOutput() + { + // Touch fields so CS0414 does not fire for legacy/reflection-only state retained for compatibility tests. + _ = _TotalCount; + _ = _Json; + _ = _Notice; + _ = _Output; + _ = _ObjectAttributes; + _ = _Schema; + _ = _Object; + _ = _Objects; + _ = _Result; + _ = _ApplicationUser; + _ = _Tags; + _ = _Owner; + _ = _Uid; + } + private Int32 _TotalCount = 0; private string _Json = string.Empty; private string _Notice = string.Empty; diff --git a/Contentstack.Core/Models/Asset.cs b/Contentstack.Core/Models/Asset.cs index d3a18037..64e48972 100644 --- a/Contentstack.Core/Models/Asset.cs +++ b/Contentstack.Core/Models/Asset.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Collections.Generic; using System.IO; using System.Linq; @@ -7,8 +8,9 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Internals; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Contentstack.Core.Models { @@ -64,7 +66,7 @@ public object this[string key] { if (e.Source != null) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } } } @@ -98,19 +100,19 @@ public string Url /// /// This is Asset type uid. /// - [JsonProperty(propertyName: "_content_type_uid")] + [JsonPropertyName("_content_type_uid")] public string ContentTypeUid { get; set; } /// /// The size of the file in bytes. /// - [JsonProperty(PropertyName = "file_size")] + [JsonPropertyName("file_size")] public string FileSize { get; set; } /// /// The original name of the file. /// - [JsonProperty(PropertyName = "filename")] + [JsonPropertyName("filename")] public string FileName { get; set; } /// @@ -121,7 +123,7 @@ public string Url /// /// Localized title of the asset (e.g. when fetched with SetLocale / asset localisation). /// - [JsonProperty(PropertyName = "title")] + [JsonPropertyName("title")] public string Title { get; set; } /// @@ -132,50 +134,50 @@ public string Url /// /// This content_type in asset. /// - [JsonProperty(propertyName: "content_type")] + [JsonPropertyName("content_type")] public string ContentType { get; set; } /// /// This for whether it is asset directory /// - [JsonProperty(propertyName: "is_dir")] + [JsonPropertyName("is_dir")] public Boolean IsDir { get; set; } /// /// Uid of user who updated the file /// - [JsonProperty(PropertyName = "updated_by")] + [JsonPropertyName("updated_by")] public string UpdatedBy { get; set; } /// /// Uid of user who updated the file /// - [JsonProperty(PropertyName = "created_by")] + [JsonPropertyName("created_by")] public string CreatedBy { get; set; } /// /// The Uid of folder in which the asset is present /// - [JsonProperty(PropertyName = "parent_uid")] + [JsonPropertyName("parent_uid")] public string ParentUid { get; set; } /// /// The Version of Asset /// - [JsonProperty(PropertyName = "_version")] + [JsonPropertyName("_version")] public string Version { get; set; } /// /// Dimension Object of the asset containing Height and width /// - [JsonProperty(PropertyName = "dimension")] + [JsonPropertyName("dimension")] public Dictionary Dimension { get; set; } /// /// Dimension Object of the asset publish details /// - [JsonProperty(PropertyName = "publish_details")] + [JsonPropertyName("publish_details")] public Dictionary PublishDetails { get; set; } #region Internal Constructors @@ -192,6 +194,7 @@ internal Asset(ContentstackClient stack) this._StackHeaders = stack._LocalHeaders; } + [JsonConstructor] internal Asset() { @@ -355,9 +358,9 @@ public void RemoveHeader(string key) } - internal void ParseObject(JObject jsonObj) + internal void ParseObject(JsonObject jsonObj) { - this._ObjectAttributes = jsonObj.ToObject>(); + this._ObjectAttributes = JsonNodeConversion.JsonObjectToDictionary(jsonObj); } public DateTime GetCreateAt() @@ -371,7 +374,7 @@ public DateTime GetCreateAt() catch (Exception e) { if (e.Source != null) - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } return DateTime.MinValue; } @@ -411,7 +414,7 @@ public DateTime GetUpdateAt() catch (Exception e) { if (e.Source != null) - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } return DateTime.MinValue; } @@ -433,7 +436,7 @@ public DateTime GetDeleteAt() catch (Exception e) { if (e.Source != null) - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } return DateTime.MinValue; } @@ -468,8 +471,8 @@ public async Task Fetch() { HttpRequestHandler RequestHandler = new HttpRequestHandler(this.StackInstance); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson, Branch: this.StackInstance.Config.Branch, timeout: this.StackInstance.Config.Timeout, proxy: this.StackInstance.Config.Proxy); - JObject obj = JObject.Parse(ContentstackConvert.ToString(outputResult, "{}")); - return obj.SelectToken("$.asset").ToObject(this.StackInstance.Serializer); + JsonObject obj = JsonNode.Parse(ContentstackConvert.ToString(outputResult, "{}"))!.AsObject(); + return JsonSerializer.Deserialize(obj["asset"]!.ToJsonString(), this.StackInstance.SerializerOptions); } catch (Exception ex) { @@ -545,21 +548,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - JObject data = JObject.Parse(errorMessage.Replace("\r\n", "")); - //errorCode = ContentstackConvert.ToInt32(data.Property("error_code").Value); - //errorMessage = ContentstackConvert.ToString(data.Property("error_message").Value); - - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) diff --git a/Contentstack.Core/Models/AssetLibrary.cs b/Contentstack.Core/Models/AssetLibrary.cs index 6a51f744..cbc7d327 100644 --- a/Contentstack.Core/Models/AssetLibrary.cs +++ b/Contentstack.Core/Models/AssetLibrary.cs @@ -6,7 +6,8 @@ using System.Threading.Tasks; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Contentstack.Core.Models { @@ -75,35 +76,31 @@ public void SortWithKeyAndOrderBy(String key, OrderBy order) /// /// ContentstackClient stack = new ContentstackClinet("api_key", "delivery_token", "environment"); /// AssetLibrary assetLibrary = stack.AssetLibrary(); - /// JObject jObject = await assetLibrary.Count(); + /// JsonObject jObject = await assetLibrary.Count(); /// /// - public async Task Count() + public async Task Count() { UrlQueries.Add("count", "true"); return await Exec(); } - public AssetLibrary Query(JObject QueryObject) + public AssetLibrary Query(JsonObject QueryObject) { try { if (UrlQueries.ContainsKey("query")) { // If query already exists, append/merge the new query object - JObject existingQuery = UrlQueries["query"] as JObject; + JsonObject existingQuery = UrlQueries["query"] as JsonObject; if (existingQuery != null) { - // Merge the new query object with the existing one - existingQuery.Merge(QueryObject, new JsonMergeSettings - { - MergeArrayHandling = MergeArrayHandling.Union - }); + JsonObjectMerge.UnionMergeInto(existingQuery, QueryObject); UrlQueries["query"] = existingQuery; } else { - // If existing query is not a JObject, replace it + // If existing query is not a JsonObject, replace it UrlQueries["query"] = QueryObject; } } @@ -147,7 +144,7 @@ public AssetLibrary Where(string key, string value) if (UrlQueries.ContainsKey("query")) { // If query already exists, get it and add the key-value pair - JObject existingQuery = UrlQueries["query"] as JObject; + JsonObject existingQuery = UrlQueries["query"] as JsonObject; if (existingQuery != null) { existingQuery[key] = value; @@ -155,8 +152,8 @@ public AssetLibrary Where(string key, string value) } else { - // If existing query is not a JObject, create a new one - JObject newQuery = new JObject(); + // If existing query is not a JsonObject, create a new one + JsonObject newQuery = new JsonObject(); newQuery[key] = value; UrlQueries["query"] = newQuery; } @@ -164,7 +161,7 @@ public AssetLibrary Where(string key, string value) else { // If query doesn't exist, create a new one with the key-value pair - JObject newQuery = new JObject(); + JsonObject newQuery = new JsonObject(); newQuery[key] = value; UrlQueries.Add("query", newQuery); } @@ -549,10 +546,15 @@ public AssetLibrary RemoveHeader(string key) /// public async Task> FetchAll() { - JObject json = await Exec(); - var assets = json.SelectToken("$.assets") - .ToObject>(this.Stack.Serializer); - var collection = json.ToObject>(this.Stack.Serializer); + JsonObject json = await Exec(); + JsonArray assetsArray = json["assets"]?.AsArray(); + IEnumerable assets = Enumerable.Empty(); + if (assetsArray != null) + { + assets = JsonSerializer.Deserialize>(assetsArray.ToJsonString(), this.Stack.SerializerOptions) + ?? new List(); + } + var collection = ContentstackCollection.FromDeliveryEnvelope(json, assets); foreach (var entry in assets) { if (entry.GetType() == typeof(Asset)) @@ -560,11 +562,10 @@ public async Task> FetchAll() (entry as Asset).SetStackInstance(this.Stack); } } - collection.Items = assets; return collection; } - private async Task Exec() + private async Task Exec() { Dictionary headers = GetHeader(_Headers); @@ -596,7 +597,7 @@ private async Task Exec() timeout: this.Stack.Config.Timeout, proxy: this.Stack.Config.Proxy ); - return JObject.Parse(ContentstackConvert.ToString(outputResult, "{}")); + return JsonNode.Parse(ContentstackConvert.ToString(outputResult, "{}"))!.AsObject(); } catch (Exception ex) { @@ -671,19 +672,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) diff --git a/Contentstack.Core/Models/ContentType.cs b/Contentstack.Core/Models/ContentType.cs index a87f7c9d..16716f6b 100644 --- a/Contentstack.Core/Models/ContentType.cs +++ b/Contentstack.Core/Models/ContentType.cs @@ -4,10 +4,9 @@ using Contentstack.Core.Internals; using System.Threading.Tasks; using System.Net; -using Newtonsoft.Json.Linq; using System.Linq; using System.IO; -using Newtonsoft.Json; +using System.Text.Json.Nodes; namespace Contentstack.Core.Models { @@ -82,19 +81,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -138,7 +125,7 @@ internal void SetStackInstance(ContentstackClient stack) /// /// is dictionary of additional parameter /// The Content-Type Schema Object. - public async Task Fetch(Dictionary param = null) + public async Task Fetch(Dictionary param = null) { Dictionary headers = GetHeader(_Headers); Dictionary headerAll = new Dictionary(); @@ -169,8 +156,8 @@ public async Task Fetch(Dictionary param = null) { HttpRequestHandler RequestHandler = new HttpRequestHandler(this.StackInstance); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson, Branch: this.StackInstance.Config.Branch, timeout: this.StackInstance.Config.Timeout, proxy: this.StackInstance.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.StackInstance.SerializerSettings); - JObject contentTypes = (Newtonsoft.Json.Linq.JObject)data["content_type"]; + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); + JsonObject contentTypes = data["content_type"]!.AsObject(); return contentTypes; } catch (Exception ex) diff --git a/Contentstack.Core/Models/ContentstackCollection.cs b/Contentstack.Core/Models/ContentstackCollection.cs index 524c4b12..73d286d3 100644 --- a/Contentstack.Core/Models/ContentstackCollection.cs +++ b/Contentstack.Core/Models/ContentstackCollection.cs @@ -1,12 +1,63 @@ -using Newtonsoft.Json; -using System.Collections; +using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.Text.Json.Nodes; namespace Contentstack.Core.Models { - [JsonObject] public class ContentstackCollection : IEnumerable { + /// + /// Builds a collection from a Delivery / Management API envelope object. + /// (Do not use JsonSerializer on this type: it implements IEnumerable and STJ would expect a JSON array at the root.) + /// + internal static ContentstackCollection FromDeliveryEnvelope(JsonObject envelope, IEnumerable items) + { + static JsonNode FindProperty(JsonObject obj, params string[] candidateNames) + { + var wanted = new HashSet(candidateNames, System.StringComparer.OrdinalIgnoreCase); + foreach (var kv in obj) + { + if (wanted.Contains(kv.Key)) + return kv.Value; + } + return null; + } + + static int ReadInt(JsonObject obj, params string[] candidateNames) + { + var node = FindProperty(obj, candidateNames); + if (node == null) + return 0; + try + { + return node.GetValue(); + } + catch + { + try + { + var s = node.ToString()?.Trim(); + if (string.IsNullOrEmpty(s)) + return 0; + return int.Parse(s, CultureInfo.InvariantCulture); + } + catch + { + return 0; + } + } + } + + return new ContentstackCollection + { + Items = items, + Skip = ReadInt(envelope, "skip"), + Limit = ReadInt(envelope, "limit"), + Count = ReadInt(envelope, "count", "total_count"), + }; + } + /// /// The number of items skipped in this resultset. /// diff --git a/Contentstack.Core/Models/Entry.cs b/Contentstack.Core/Models/Entry.cs index d683ecdb..dc0af276 100644 --- a/Contentstack.Core/Models/Entry.cs +++ b/Contentstack.Core/Models/Entry.cs @@ -1,5 +1,4 @@ using Markdig; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; @@ -8,7 +7,9 @@ using System.Threading.Tasks; using Contentstack.Core.Internals; using Contentstack.Core.Configuration; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Contentstack.Core.Models { @@ -24,7 +25,7 @@ public class Entry private CachePolicy _CachePolicy; private Dictionary UrlQueries = new Dictionary(); private bool _IsCachePolicySet; - private JObject jObject; + private JsonObject jObject; private string _Url { get @@ -113,7 +114,7 @@ private string _Url /// /// Dimension Object of the entries publish details /// - [JsonProperty(PropertyName = "publish_details")] + [JsonPropertyName("publish_details")] public Dictionary PublishDetails { get; set; } /// @@ -159,6 +160,7 @@ public Dictionary Object #endregion #region Internal Constructors + [JsonConstructor] internal Entry() { } @@ -190,19 +192,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -867,7 +857,7 @@ public String GetDeletedBy() /// }); /// /// - public JObject ToJson() + public JsonObject ToJson() { return this.jObject; } @@ -892,7 +882,7 @@ public JObject ToJson() private Asset GetAsset(String key) { - JObject assetObject = (JObject)jObject.GetValue(key); + JsonObject assetObject = jObject[key]!.AsObject(); var asset = ContentTypeInstance.StackInstance.Asset(); asset.ParseObject(assetObject); return asset; @@ -917,12 +907,11 @@ private Asset GetAsset(String key) private List GetAssets(String key) { List assets = new List(); - JArray assetArray = (Newtonsoft.Json.Linq.JArray)jObject.GetValue(key); - //Dictionary assetArray = (Dictionary)_ObjectAttributes[key]; + JsonArray assetArray = jObject[key]!.AsArray(); - foreach (JToken v in assetArray) + foreach (JsonNode v in assetArray) { - JObject assetobj = (JObject)v; + JsonObject assetobj = v.AsObject(); Asset asset = ContentTypeInstance.StackInstance.Asset(); asset.ParseObject(assetobj); assets.Add(asset); @@ -1455,8 +1444,8 @@ public async Task Fetch() HttpRequestHandler RequestHandler = new HttpRequestHandler(this.ContentTypeInstance.StackInstance); var outputResult = await RequestHandler.ProcessRequest(_Url, headerAll, mainJson, Branch: this.ContentTypeInstance.StackInstance.Config.Branch, isLivePreview: isLivePreview, timeout: this.ContentTypeInstance.StackInstance.Config.Timeout, proxy: this.ContentTypeInstance.StackInstance.Config.Proxy); - JObject obj = JObject.Parse(ContentstackConvert.ToString(outputResult, "{}")); - var serializedObject = obj.SelectToken("$.entry").ToObject(this.ContentTypeInstance.StackInstance.Serializer); + JsonObject obj = JsonNode.Parse(ContentstackConvert.ToString(outputResult, "{}"))!.AsObject(); + var serializedObject = JsonSerializer.Deserialize(obj["entry"]!.ToJsonString(), this.ContentTypeInstance.StackInstance.SerializerOptions); if (serializedObject.GetType() == typeof(Entry)) { (serializedObject as Entry).ContentTypeInstance = this.ContentTypeInstance; @@ -1522,24 +1511,14 @@ private Dictionary GetHeader(Dictionary localHea } } - internal void ParseObject(JObject jsonObj, string url = null) + internal void ParseObject(JsonObject jsonObj, string url = null) { this.jObject = jsonObj; - this._ObjectAttributes = jsonObj.ToObject>(); - if (_ObjectAttributes != null && _ObjectAttributes.ContainsKey("_metadata")) + this._ObjectAttributes = JsonNodeConversion.JsonObjectToDictionary(jsonObj); + if (_ObjectAttributes != null && _ObjectAttributes.TryGetValue("_metadata", out var mdObj) + && mdObj is Dictionary mdDict) { - var jObject = (Newtonsoft.Json.Linq.JObject)_ObjectAttributes["_metadata"]; - var _metadataJSON = new Dictionary(); - foreach (var property in jObject.Properties()) - { - _metadataJSON[property.Name] = property.Value.ToObject(); - } - List iterator = _metadataJSON.Keys.ToList(); - Metadata = new Dictionary(); - foreach (var key in iterator) - { - Metadata.Add(key, _metadataJSON[key]); - } + Metadata = new Dictionary(mdDict); } } diff --git a/Contentstack.Core/Models/GlobalField.cs b/Contentstack.Core/Models/GlobalField.cs index 46c39438..f36b8bd5 100644 --- a/Contentstack.Core/Models/GlobalField.cs +++ b/Contentstack.Core/Models/GlobalField.cs @@ -4,10 +4,9 @@ using Contentstack.Core.Internals; using System.Threading.Tasks; using System.Net; -using Newtonsoft.Json.Linq; using System.Linq; using System.IO; -using Newtonsoft.Json; +using System.Text.Json.Nodes; namespace Contentstack.Core.Models { @@ -96,19 +95,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -152,7 +139,7 @@ internal void SetStackInstance(ContentstackClient stack) /// /// A dictionary of additional parameters. /// The Global Field schema object. - public async Task Fetch(Dictionary param = null) + public async Task Fetch(Dictionary param = null) { Dictionary headers = GetHeader(_Headers); Dictionary headerAll = new Dictionary(); @@ -181,9 +168,9 @@ public async Task Fetch(Dictionary param = null) { HttpRequestHandler RequestHandler = new HttpRequestHandler(this.StackInstance); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson, Branch: this.StackInstance.Config.Branch, timeout: this.StackInstance.Config.Timeout, proxy: this.StackInstance.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.StackInstance.SerializerSettings); + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); - JObject globalTypes = (Newtonsoft.Json.Linq.JObject)data["global_field"]; + JsonObject globalTypes = data["global_field"]!.AsObject(); return globalTypes; } catch (Exception ex) diff --git a/Contentstack.Core/Models/GlobalFieldQuery.cs b/Contentstack.Core/Models/GlobalFieldQuery.cs index d804c1a9..f11a50ef 100644 --- a/Contentstack.Core/Models/GlobalFieldQuery.cs +++ b/Contentstack.Core/Models/GlobalFieldQuery.cs @@ -4,10 +4,10 @@ using Contentstack.Core.Internals; using System.Threading.Tasks; using System.Net; -using Newtonsoft.Json.Linq; using System.Linq; using System.IO; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Contentstack.Core.Models { @@ -66,19 +66,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -122,7 +110,7 @@ internal void SetStackInstance(ContentstackClient stack) /// /// A dictionary of additional parameters. /// The global field schema object. - public async Task Find(Dictionary param = null) + public async Task Find(Dictionary param = null) { Dictionary headers = GetHeader(_Headers); Dictionary headerAll = new Dictionary(); @@ -150,7 +138,7 @@ public async Task Find(Dictionary param = null) { HttpRequestHandler RequestHandler = new HttpRequestHandler(this.StackInstance); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson, Branch: this.StackInstance.Config.Branch, timeout: this.StackInstance.Config.Timeout, proxy: this.StackInstance.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.StackInstance.SerializerSettings); + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); return data; } diff --git a/Contentstack.Core/Models/Query.cs b/Contentstack.Core/Models/Query.cs index c0d6ead1..7e2608d2 100644 --- a/Contentstack.Core/Models/Query.cs +++ b/Contentstack.Core/Models/Query.cs @@ -1,5 +1,7 @@ -using Newtonsoft.Json.Linq; using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Collections.Generic; using System.IO; using System.Linq; @@ -93,19 +95,7 @@ internal static ContentstackException GetContentstackError(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - 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>(); + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); var response = exResp as HttpWebResponse; if (response != null) @@ -1267,7 +1257,7 @@ public Query Only(String[] fieldUid) } catch (Exception e) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } return this; @@ -1336,7 +1326,7 @@ public Query Except(String[] fieldUids) } catch (Exception e) { - Console.WriteLine(ErrorMessages.FormatExceptionDetails(e)); + Debug.WriteLine(ErrorMessages.FormatExceptionDetails(e)); } return this; } @@ -1798,7 +1788,7 @@ public async Task> Find() /// }); /// /// - public async Task Count() + public async Task Count() { try { @@ -1839,35 +1829,54 @@ public async Task> FindOne() return this.parseJObject(await Exec()); } - private ContentstackCollection parseJObject(JObject jObject) + private ContentstackCollection parseJObject(JsonObject jObject) { - - if(this.TaxonomyInstance!=null) + JsonSerializerOptions opts = this.TaxonomyInstance != null + ? this.TaxonomyInstance.SerializerOptions + : this.ContentTypeInstance.StackInstance.SerializerOptions; + + JsonArray entriesArray = jObject["entries"]?.AsArray(); + IEnumerable entries = Enumerable.Empty(); + + try { - var entries = jObject.SelectToken("$.entries").ToObject>(this.TaxonomyInstance.Serializer); - var collection = jObject.ToObject>(this.TaxonomyInstance.Serializer); - collection.Items = entries; - return collection; + if (entriesArray != null) + { + entries = JsonSerializer.Deserialize>(entriesArray.ToJsonString(), opts) + ?? new List(); + } + } + catch (JsonException ex) + { + var preview = jObject.ToJsonString(); + if (preview.Length > 500) + { + preview = preview.Substring(0, 500) + "..."; + } + + throw new ContentstackException( + "Could not deserialize entry documents from the query response. Response preview: " + preview, + ex); + } + + var collection = ContentstackCollection.FromDeliveryEnvelope(jObject, entries); - } else + if (this.TaxonomyInstance == null) { - var entries = jObject.SelectToken("$.entries").ToObject>(this.ContentTypeInstance.StackInstance.Serializer); - var collection = jObject.ToObject>(this.ContentTypeInstance.StackInstance.Serializer); foreach (var entry in entries) { - if (entry.GetType() == typeof(Entry)) + if (entry != null && entry.GetType() == typeof(Entry)) { (entry as Entry).SetContentTypeInstance(this.ContentTypeInstance); } } - collection.Items = entries; - return collection; } - + + return collection; } #endregion - private async Task Exec() + private async Task Exec() { Dictionary headers = GetHeader(_Headers); Dictionary headerAll = new Dictionary(); @@ -1957,13 +1966,13 @@ private async Task Exec() HttpRequestHandler requestHandler = new HttpRequestHandler(this.TaxonomyInstance); var branch = this.TaxonomyInstance.Config.Branch != null ? this.TaxonomyInstance.Config.Branch : "main"; var outputResult = await requestHandler.ProcessRequest(this._Url, headerAll, mainJson, Branch: branch, isLivePreview: isLivePreview, timeout: this.TaxonomyInstance.Config.Timeout); - return JObject.Parse(ContentstackConvert.ToString(outputResult, "{}")); + return JsonNode.Parse(ContentstackConvert.ToString(outputResult, "{}"))!.AsObject(); } else { HttpRequestHandler requestHandler = new HttpRequestHandler(this.ContentTypeInstance.StackInstance); var outputResult = await requestHandler.ProcessRequest(_Url, headerAll, mainJson, Branch: this.ContentTypeInstance.StackInstance.Config.Branch, isLivePreview: isLivePreview, timeout: this.ContentTypeInstance.StackInstance.Config.Timeout); - return JObject.Parse(ContentstackConvert.ToString(outputResult, "{}")); + return JsonNode.Parse(ContentstackConvert.ToString(outputResult, "{}"))!.AsObject(); } } catch (Exception ex) diff --git a/Contentstack.Core/Models/SyncStack.cs b/Contentstack.Core/Models/SyncStack.cs index bd531dce..54d2b94d 100644 --- a/Contentstack.Core/Models/SyncStack.cs +++ b/Contentstack.Core/Models/SyncStack.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; -using Newtonsoft.Json; - +using System.Text.Json.Serialization; + namespace Contentstack.Core.Models -{ +{ /// /// Represents the result of a sync operation. /// @@ -15,17 +15,17 @@ public class SyncStack /// /// Readonly property to check totalCount /// - [JsonProperty("total_count")] - public int TotalCount { get; set; } + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } /// /// Readonly property to delta sync. /// - [JsonProperty("sync_token")] - public string SyncToken { get; set; } + [JsonPropertyName("sync_token")] + public string SyncToken { get; set; } /// /// Readonly property for paginating sync /// - [JsonProperty("pagination_token")] + [JsonPropertyName("pagination_token")] public string PaginationToken { get; set;} } } \ No newline at end of file diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index 9d7322f0..667915df 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -4,8 +4,6 @@ using System.Net; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; -using Newtonsoft.Json.Linq; - namespace Contentstack.Core.Models { public class Taxonomy: Query @@ -34,7 +32,7 @@ protected override string _Url } } #endregion - public ContentstackClient Stack + public new ContentstackClient Stack { get; set; @@ -254,7 +252,7 @@ private Dictionary GetHeader(Dictionary localHea return _StackHeaders; } } - internal static ContentstackException GetContentstackError(Exception ex) + internal static new ContentstackException GetContentstackError(Exception ex) { Int32 errorCode = 0; string errorMessage = string.Empty; @@ -280,27 +278,7 @@ internal static ContentstackException GetContentstackError(Exception ex) 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() - } + ApiErrorBodyParser.TryApply(errorMessage.Replace("\r\n", ""), ref errorCode, ref errorMessage, ref errors); } var response = exResp as HttpWebResponse; diff --git a/Directory.Build.props b/Directory.Build.props index 0780faed..6c70934b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 2.26.0 + 3.0.0-beta.1 From e6b152fcb6f4fd6cdc7e1ac8a1f165e7ac860bd9 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:35:35 +0530 Subject: [PATCH 02/11] Updated dotnet version --- .github/workflows/sca-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index 21e97963..274c89b8 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -13,7 +13,7 @@ jobs: - name: Setup .NET Core @ Latest uses: actions/setup-dotnet@v1 with: - dotnet-version: "7.0.x" + dotnet-version: "10.0.x" - name: Run Dotnet Restore run: dotnet restore From 5151ae5c45b45e71586fb973e9564d32c7981d04 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:47:16 +0530 Subject: [PATCH 03/11] Updated version bumps --- .github/workflows/sca-scan.yml | 18 +++---- .../Contentstack.Core.Tests.csproj | 4 +- .../Contentstack.Core.Unit.Tests.csproj | 4 +- Contentstack.Core/Contentstack.Core.csproj | 8 ++-- Contentstack.Net.sln | 47 +++++++++++++++++++ 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index 274c89b8..b6574fe4 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -10,25 +10,25 @@ jobs: - name: Checkout repository uses: actions/checkout@master - - name: Setup .NET Core @ Latest - uses: actions/setup-dotnet@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.x" - - name: Run Dotnet Restore - run: dotnet restore + - name: Restore + run: dotnet restore Contentstack.Net.sln - - name: Setup Snyk - uses: snyk/actions/setup@master # just installs Snyk CLI, no deprecated dotnet action + - name: Setup Snyk CLI + uses: snyk/actions/setup@master - - name: Run Snyk to check for vulnerabilities + - name: Snyk test (Contentstack.Core lockfile) run: | snyk test \ --file=Contentstack.Core/obj/project.assets.json \ --fail-on=all \ - --json-file-output=snyk.json # ← writes snyk.json to disk + --json-file-output=snyk.json env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - continue-on-error: true # ← let pipeline continue even if vulns found + continue-on-error: true - uses: contentstack/sca-policy@main diff --git a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj index 4dcac804..3f6bef2e 100644 --- a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj +++ b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj @@ -18,8 +18,8 @@ all - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj index cfbb4691..74af9458 100644 --- a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj +++ b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj @@ -19,8 +19,8 @@ - - + + diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index 7b05f32a..91031e5f 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -29,10 +29,10 @@ - - - - + + + + diff --git a/Contentstack.Net.sln b/Contentstack.Net.sln index 91f1fc07..05b3eee6 100644 --- a/Contentstack.Net.sln +++ b/Contentstack.Net.sln @@ -16,6 +16,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contentstack.Core.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contentstack.Core.Unit.Tests", "Contentstack.Core.Unit.Tests\Contentstack.Core.Unit.Tests.csproj", "{78071515-4508-480C-B88A-D9AADFC77C39}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntryFetchSmoke", "samples\EntryFetchSmoke\EntryFetchSmoke.csproj", "{D0E53C2F-F31F-4FA6-8C74-013AC094FA39}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -200,10 +204,53 @@ Global {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x64.Build.0 = Release|Any CPU {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x86.ActiveCfg = Release|Any CPU {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x86.Build.0 = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x64.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x86.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhone.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x64.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x64.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x86.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x86.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhone.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x64.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x86.Build.0 = Debug|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|Any CPU.Build.0 = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhone.ActiveCfg = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhone.Build.0 = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x64.ActiveCfg = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x64.Build.0 = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x86.ActiveCfg = Release|Any CPU + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D0E53C2F-F31F-4FA6-8C74-013AC094FA39} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution Policies = $0 $0.DotNetNamingPolicy = $1 From e07f21b36398242d7a8b6bdec11acaf0eb55f0b6 Mon Sep 17 00:00:00 2001 From: sunil-lakshman <104969541+sunil-lakshman@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:50:18 +0530 Subject: [PATCH 04/11] Revert "Updated version bumps" This reverts commit 5150ae5c45b45e71586fb973e9564d32c7981d04. --- .github/workflows/sca-scan.yml | 18 +++---- .../Contentstack.Core.Tests.csproj | 4 +- .../Contentstack.Core.Unit.Tests.csproj | 4 +- Contentstack.Core/Contentstack.Core.csproj | 8 ++-- Contentstack.Net.sln | 47 ------------------- 5 files changed, 17 insertions(+), 64 deletions(-) diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index b6574fe4..274c89b8 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -10,25 +10,25 @@ jobs: - name: Checkout repository uses: actions/checkout@master - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Setup .NET Core @ Latest + uses: actions/setup-dotnet@v1 with: dotnet-version: "10.0.x" - - name: Restore - run: dotnet restore Contentstack.Net.sln + - name: Run Dotnet Restore + run: dotnet restore - - name: Setup Snyk CLI - uses: snyk/actions/setup@master + - name: Setup Snyk + uses: snyk/actions/setup@master # just installs Snyk CLI, no deprecated dotnet action - - name: Snyk test (Contentstack.Core lockfile) + - name: Run Snyk to check for vulnerabilities run: | snyk test \ --file=Contentstack.Core/obj/project.assets.json \ --fail-on=all \ - --json-file-output=snyk.json + --json-file-output=snyk.json # ← writes snyk.json to disk env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - continue-on-error: true + continue-on-error: true # ← let pipeline continue even if vulns found - uses: contentstack/sca-policy@main diff --git a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj index 3f6bef2e..4dcac804 100644 --- a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj +++ b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj @@ -18,8 +18,8 @@ all - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj index 74af9458..cfbb4691 100644 --- a/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj +++ b/Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj @@ -19,8 +19,8 @@ - - + + diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index 91031e5f..7b05f32a 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -29,10 +29,10 @@ - - - - + + + + diff --git a/Contentstack.Net.sln b/Contentstack.Net.sln index 05b3eee6..91f1fc07 100644 --- a/Contentstack.Net.sln +++ b/Contentstack.Net.sln @@ -16,10 +16,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contentstack.Core.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contentstack.Core.Unit.Tests", "Contentstack.Core.Unit.Tests\Contentstack.Core.Unit.Tests.csproj", "{78071515-4508-480C-B88A-D9AADFC77C39}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntryFetchSmoke", "samples\EntryFetchSmoke\EntryFetchSmoke.csproj", "{D0E53C2F-F31F-4FA6-8C74-013AC094FA39}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -204,53 +200,10 @@ Global {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x64.Build.0 = Release|Any CPU {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x86.ActiveCfg = Release|Any CPU {78071515-4508-480C-B88A-D9AADFC77C39}.Release|x86.Build.0 = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x64.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Ad-Hoc|x86.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|Any CPU.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhone.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhone.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x64.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x64.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x86.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.AppStore|x86.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhone.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x64.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x64.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x86.ActiveCfg = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Debug|x86.Build.0 = Debug|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|Any CPU.Build.0 = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhone.ActiveCfg = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhone.Build.0 = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x64.ActiveCfg = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x64.Build.0 = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x86.ActiveCfg = Release|Any CPU - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {D0E53C2F-F31F-4FA6-8C74-013AC094FA39} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution Policies = $0 $0.DotNetNamingPolicy = $1 From 2e8ed7ac3aa2b84ad1b2f50d3af1fe5254fc03c6 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Mon, 4 May 2026 15:04:59 +0530 Subject: [PATCH 05/11] chore: updated utils version bump --- Contentstack.Core/Contentstack.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index 7b05f32a..bb62bbb9 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -32,7 +32,7 @@ - + From 2e8a384b2d66cf2ca5a57f2a47e06104283ea9fc Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Tue, 9 Jun 2026 15:30:32 +0530 Subject: [PATCH 06/11] update the version bump --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ Directory.Build.props | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e03361..eff5fe2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +### Version: 3.0.0 +#### Date: Jun-15-2026 + +##### Feat: +- Migrated all internal JSON handling to `System.Text.Json` +- Added `ApiErrorBodyParser` for consistent API error envelope parsing +- Added `JsonNodeConversion` and `JsonObjectMerge` utilities to replace Newtonsoft equivalents +- Added `ContentstackJsonDefaults` — shared `JsonSerializerOptions` used across all custom converters + +##### Enh: +- Replaced `Console.WriteLine` with `Debug.WriteLine` in `ContentstackConvert` to suppress parse warnings from application stdout + +##### Chore: +- Updated .NET version in SCA scan CI from `7.0.x` to `10.0.x` + +--- + +### Version: 3.0.0-beta.1 +#### Date: May-04-2026 + +##### Breaking Changes: +- Removed `Newtonsoft.Json` dependency; all JSON serialisation now uses `System.Text.Json` (BCL) + - `AssetJsonConverter` and `EntryJsonConverter` now implement `System.Text.Json.Serialization.JsonConverter` + - `ContentstackCollection` no longer carries `[JsonObject]`; direct `JsonSerializer` usage on this type is not supported +- Updated `contentstack.utils` from `1.0.6` to `2.0.0-beta.1` (major version bump) + +##### Feat: +- Migrated all internal JSON handling to `System.Text.Json` +- Added `ApiErrorBodyParser` for consistent API error envelope parsing +- Added `JsonNodeConversion` and `JsonObjectMerge` utilities to replace Newtonsoft equivalents +- Added `ContentstackJsonDefaults` — shared `JsonSerializerOptions` used across all custom converters + +##### Enh: +- Replaced `Console.WriteLine` with `Debug.WriteLine` in `ContentstackConvert` to suppress parse warnings from application stdout + +##### Chore: +- Updated .NET version in SCA scan CI from `7.0.x` to `10.0.x` + +--- + ### Version: 2.26.0 #### Date: Feb-10-2026 diff --git a/Directory.Build.props b/Directory.Build.props index 6c70934b..1f737f6c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 3.0.0-beta.1 + 3.0.0 From 9706449ad5aebbc40f86a246caf4581391c42aac Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Fri, 19 Jun 2026 16:24:09 +0530 Subject: [PATCH 07/11] feat: dynamic endpoint resolution via CDN-backed regions registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added Endpoint class — resolves service URLs from regions.json with 3-tier cache (memory → disk → CDN) - Added ContentstackRegionMap — bridges ContentstackRegion enum to registry region IDs - Added GCP_EU to ContentstackRegion enum - Replaced hardcoded regionCode() and HostURL in Config.BaseUrl with Endpoint lookup - Replaced refresh-region.cs with refresh-region.py to prevent MSBuild from compiling the script as source - Added build/contentstack.csharp.targets to auto-deliver refresh-region.py on first consumer build - Added Assets/regions.json to .gitignore - Added EndpointTest.cs --- .gitignore | 4 +- CHANGELOG.md | 18 +- Contentstack.Core.Tests/EndpointTest.cs | 289 ++++++++++++++++++ Contentstack.Core/Configuration/Config.cs | 42 +-- Contentstack.Core/Contentstack.Core.csproj | 20 +- Contentstack.Core/Endpoints/Endpoint.cs | 287 +++++++++++++++++ .../Internals/ContentstackRegion.cs | 32 +- Scripts/refresh-region.py | 73 +++++ build/contentstack.csharp.targets | 20 ++ 9 files changed, 745 insertions(+), 40 deletions(-) create mode 100644 Contentstack.Core.Tests/EndpointTest.cs create mode 100644 Contentstack.Core/Endpoints/Endpoint.cs create mode 100644 Scripts/refresh-region.py create mode 100644 build/contentstack.csharp.targets diff --git a/.gitignore b/.gitignore index 152183fd..9d4a4f27 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,6 @@ packages/ *.sln.docstates # Python -Scripts/venv/ \ No newline at end of file +Scripts/venv/ +# Cached region registry — regenerated at runtime from CDN +*/Assets/regions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index eff5fe2a..aa500fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,19 @@ -### Version: 3.0.0 -#### Date: Jun-15-2026 +### Version: 3.0.0-beta.2 +#### Date: Jun-22-2026 ##### Feat: -- Migrated all internal JSON handling to `System.Text.Json` -- Added `ApiErrorBodyParser` for consistent API error envelope parsing -- Added `JsonNodeConversion` and `JsonObjectMerge` utilities to replace Newtonsoft equivalents -- Added `ContentstackJsonDefaults` — shared `JsonSerializerOptions` used across all custom converters +- Added `Endpoint` class for dynamic region-to-URL resolution via CDN-backed `regions.json` +- Added `ContentstackRegionMap` to map `ContentstackRegion` enum to registry region IDs +- Added `GCP_EU` region support ##### Enh: -- Replaced `Console.WriteLine` with `Debug.WriteLine` in `ContentstackConvert` to suppress parse warnings from application stdout +- `Config.BaseUrl` now resolves hosts from the regions registry; removed hardcoded `regionCode()` and `HostURL` ##### Chore: -- Updated .NET version in SCA scan CI from `7.0.x` to `10.0.x` +- Replaced `refresh-region.cs` with `refresh-region.py` — avoids MSBuild compiling the script as source +- Added `build/contentstack.csharp.targets` to auto-deliver `refresh-region.py` to consumer projects on first build +- Added `Assets/regions.json` to `.gitignore` +- Added `EndpointTest.cs` --- diff --git a/Contentstack.Core.Tests/EndpointTest.cs b/Contentstack.Core.Tests/EndpointTest.cs new file mode 100644 index 00000000..ad64e16c --- /dev/null +++ b/Contentstack.Core.Tests/EndpointTest.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Contentstack.Core.Endpoints; +using Xunit; + +namespace Contentstack.Core.Tests +{ + /// + /// Tests for — dynamic CDN-backed region resolution. + /// Mirrors the coverage from contentstack-utils-dotnet PR #66 EndpointTest.cs, + /// adapted for the Delivery SDK (contentDelivery as primary service key). + /// + public class EndpointTest : IDisposable + { + public EndpointTest() + { + // Each test starts with a clean cache so CDN/disk state doesn't bleed across tests. + Endpoint.ResetCache(); + } + + public void Dispose() + { + Endpoint.ResetCache(); + } + + // ── Basic resolution ────────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_Na_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + Assert.Equal("https://cdn.contentstack.io", url); + } + + [Fact] + public void GetContentstackEndpoint_Eu_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("eu", "contentDelivery"); + Assert.Equal("https://eu-cdn.contentstack.com", url); + } + + [Fact] + public void GetContentstackEndpoint_Au_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("au", "contentDelivery"); + Assert.Equal("https://au-cdn.contentstack.com", url); + } + + [Fact] + public void GetContentstackEndpoint_AzureNa_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("azure-na", "contentDelivery"); + Assert.Equal("https://azure-na-cdn.contentstack.com", url); + } + + [Fact] + public void GetContentstackEndpoint_AzureEu_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("azure-eu", "contentDelivery"); + Assert.Equal("https://azure-eu-cdn.contentstack.com", url); + } + + [Fact] + public void GetContentstackEndpoint_GcpNa_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("gcp-na", "contentDelivery"); + Assert.Equal("https://gcp-na-cdn.contentstack.com", url); + } + + [Fact] + public void GetContentstackEndpoint_GcpEu_ReturnsCorrectCdnUrl() + { + string url = Endpoint.GetContentstackEndpoint("gcp-eu", "contentDelivery"); + Assert.Equal("https://gcp-eu-cdn.contentstack.com", url); + } + + // ── NA alias resolution ─────────────────────────────────────────────── + + [Theory] + [InlineData("na")] + [InlineData("us")] + [InlineData("NA")] + [InlineData("US")] + [InlineData("AWS-NA")] + [InlineData("aws_na")] + [InlineData("AWS_NA")] + public void GetContentstackEndpoint_NaAliasVariants_AllResolveToSameCdn(string alias) + { + string url = Endpoint.GetContentstackEndpoint(alias, "contentDelivery"); + Assert.Equal("https://cdn.contentstack.io", url); + } + + // ── omitHttps flag ──────────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_OmitHttps_ReturnsHostOnly() + { + string host = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: true); + Assert.Equal("cdn.contentstack.io", host); + } + + [Fact] + public void GetContentstackEndpoint_OmitHttps_Eu_ReturnsHostOnly() + { + string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true); + Assert.Equal("eu-cdn.contentstack.com", host); + } + + [Fact] + public void GetContentstackEndpoint_OmitHttpsFalse_ReturnsFullUrl() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: false); + Assert.StartsWith("https://", url); + } + + // ── Management endpoint (also valid for Delivery SDK to know) ───────── + + [Fact] + public void GetContentstackEndpoint_Na_ContentManagement_ReturnsApiUrl() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentManagement"); + Assert.Equal("https://api.contentstack.io", url); + } + + // ── Dictionary overload ─────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_Na_ContainsContentDelivery() + { + Dictionary all = Endpoint.GetContentstackEndpoint("na"); + Assert.True(all.ContainsKey("contentDelivery")); + Assert.Equal("https://cdn.contentstack.io", all["contentDelivery"]); + } + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_Na_HasExpectedKeyCount() + { + Dictionary all = Endpoint.GetContentstackEndpoint("na"); + Assert.True(all.Count >= 18, $"Expected at least 18 service keys, got {all.Count}"); + } + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_OmitHttps_AllValuesLackScheme() + { + Dictionary all = Endpoint.GetContentstackEndpoint("na", omitHttps: true); + foreach (var kvp in all) + Assert.DoesNotMatch(@"^https?://", kvp.Value); + } + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_Eu_ContainsContentDelivery() + { + Dictionary all = Endpoint.GetContentstackEndpoint("eu"); + Assert.True(all.ContainsKey("contentDelivery")); + } + + // ── Error handling ──────────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_EmptyRegion_ThrowsArgumentException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("", "contentDelivery")); + } + + [Fact] + public void GetContentstackEndpoint_WhitespaceRegion_ThrowsArgumentException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint(" ", "contentDelivery")); + } + + [Fact] + public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("invalid-region-xyz", "contentDelivery")); + } + + [Fact] + public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("na", "nonExistentService")); + } + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_EmptyRegion_ThrowsArgumentException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("", omitHttps: false)); + } + + [Fact] + public void GetContentstackEndpoint_DictionaryOverload_UnknownRegion_ThrowsKeyNotFoundException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("invalid-region-xyz")); + } + + // ── Cache consistency ───────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_CalledTwice_ReturnsSameResult() + { + string url1 = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + string url2 = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + Assert.Equal(url1, url2); + } + + [Fact] + public void ResetCache_ThenResolve_StillReturnsCorrectUrl() + { + // Warm the cache + Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + + // Reset and resolve again + Endpoint.ResetCache(); + string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + Assert.Equal("https://cdn.contentstack.io", url); + } + + // ── Local file path ─────────────────────────────────────────────────── + + [Fact] + public void GetLocalFilePath_ContainsAssetsAndRegionsJson() + { + string path = Endpoint.GetLocalFilePath(); + Assert.Contains("Assets", path); + Assert.EndsWith("regions.json", path); + } + + [Fact] + public void GetLocalFilePath_PathIsAbsolute() + { + string path = Endpoint.GetLocalFilePath(); + Assert.True(Path.IsPathRooted(path), $"Expected absolute path, got: {path}"); + } + + // ── Local file self-heal ────────────────────────────────────────────── + + [Fact] + public void GetContentstackEndpoint_WritesLocalFile_AfterCdnDownload() + { + // Delete local file to force CDN download + string localFile = Endpoint.GetLocalFilePath(); + if (File.Exists(localFile)) + File.Delete(localFile); + + Endpoint.ResetCache(); + + // This call should trigger CDN download and write the file + Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + + Assert.True(File.Exists(localFile), $"Expected regions.json to be written to: {localFile}"); + } + + // ── URL format validation ───────────────────────────────────────────── + + [Theory] + [InlineData("na")] + [InlineData("eu")] + [InlineData("au")] + [InlineData("azure-na")] + [InlineData("azure-eu")] + [InlineData("gcp-na")] + [InlineData("gcp-eu")] + public void GetContentstackEndpoint_AllRegions_ContentDelivery_StartsWithHttps(string region) + { + string url = Endpoint.GetContentstackEndpoint(region, "contentDelivery"); + Assert.StartsWith("https://", url); + } + + [Theory] + [InlineData("na")] + [InlineData("eu")] + [InlineData("au")] + [InlineData("azure-na")] + [InlineData("azure-eu")] + [InlineData("gcp-na")] + [InlineData("gcp-eu")] + public void GetContentstackEndpoint_AllRegions_OmitHttps_DoesNotStartWithHttps(string region) + { + string host = Endpoint.GetContentstackEndpoint(region, "contentDelivery", omitHttps: true); + Assert.DoesNotMatch(@"^https?://", host); + } + } +} diff --git a/Contentstack.Core/Configuration/Config.cs b/Contentstack.Core/Configuration/Config.cs index d5ce4794..74b8326c 100644 --- a/Contentstack.Core/Configuration/Config.cs +++ b/Contentstack.Core/Configuration/Config.cs @@ -1,6 +1,5 @@ -using System; -using System.Linq; -using System.Net; +using System.Net; +using Contentstack.Core.Endpoints; using Contentstack.Core.Internals; namespace Contentstack.Core.Configuration @@ -35,8 +34,8 @@ public string Protocol { set { this._Protocol = value; } } - public string Host { - get { return _Host ?? HostURL; } + public string Host { + get { return _Host ?? string.Empty; } set { this._Host = value; } } @@ -76,12 +75,17 @@ public string BaseUrl { get { - string BaseURL = string.Format("{0}://{1}{2}/{3}", - this.Protocol.Trim('/').Trim('\\'), - regionCode(), - this.Host.Trim('/').Trim('\\'), - this.Version.Trim('/').Trim('\\')); - return BaseURL; + string protocol = this.Protocol.Trim('/').Trim('\\'); + string version = this.Version.Trim('/').Trim('\\'); + + // Custom host explicitly set — bypass CDN registry and use it directly. + if (!string.IsNullOrEmpty(_Host)) + return string.Format("{0}://{1}/{2}", protocol, _Host.Trim('/').Trim('\\'), version); + + // Resolve host from CDN-backed regions registry. + string regionId = ContentstackRegionMap.RegionIdMap[Region]; + string host = Endpoint.GetContentstackEndpoint(regionId, "contentDelivery", omitHttps: true); + return string.Format("{0}://{1}/{2}", protocol, host, version); } } @@ -109,22 +113,6 @@ internal string getBaseUrl (LivePreviewConfig livePreviewConfig, string contentT return BaseUrl; } - internal string regionCode() - { - if (Region == ContentstackRegion.US) return ""; - ContentstackRegionCode[] regionCodes = Enum.GetValues(typeof(ContentstackRegionCode)).Cast().ToArray(); - return string.Format("{0}-", regionCodes[(int)Region].ToString().Replace("_", "-")); - } - - internal string HostURL - { - get - { - if (Region == ContentstackRegion.EU || Region == ContentstackRegion.AZURE_EU || Region == ContentstackRegion.AZURE_NA || Region == ContentstackRegion.GCP_NA || Region==ContentstackRegion.AU) - return "cdn.contentstack.com"; - return "cdn.contentstack.io"; - } - } #endregion } } diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index bb62bbb9..db1751b2 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net47;net472; + net10.0 latest contentstack.csharp Contentstack @@ -33,6 +33,7 @@ + @@ -47,11 +48,28 @@ + + + + + true + contentFiles/cs/any/Scripts/refresh-region.py + Content + false + + + true + build/contentstack.csharp.targets + + diff --git a/Contentstack.Core/Endpoints/Endpoint.cs b/Contentstack.Core/Endpoints/Endpoint.cs new file mode 100644 index 00000000..418d8539 --- /dev/null +++ b/Contentstack.Core/Endpoints/Endpoint.cs @@ -0,0 +1,287 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Contentstack.Core.Endpoints +{ + /// + /// Resolves Contentstack service URLs for any supported region. + /// + /// All public methods are static — no instantiation required. + /// + /// Example: + /// + /// // Full URL + /// string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + /// // → "https://cdn.contentstack.io" + /// + /// // Host only (omit https://) — useful for SDK host configuration + /// string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true); + /// // → "eu-cdn.contentstack.com" + /// + /// // All endpoints for a region + /// Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("azure-na"); + /// // → { "contentDelivery": "...", "contentManagement": "...", ... } + /// + /// + public static class Endpoint + { + private const string RegionsUrl = "https://artifacts.contentstack.com/regions.json"; + + // Module-level cache — loaded once per process, shared across all calls. + private static JsonElement[]? _regionsData; + private static readonly object _cacheLock = new object(); + private static readonly HttpClient _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + /// + /// Resolves a single Contentstack service endpoint URL for the given region. + /// + /// + /// Region ID or alias (case-insensitive). Examples: "na", "us", "eu", "AWS-NA", "azure_eu", "gcp-na". + /// + /// + /// Service key. Valid keys include: "contentDelivery", "contentManagement", "auth", + /// "graphqlDelivery", "preview", "images", "assets", "automate", "launch", + /// "developerHub", "brandKit", "genAI", "personalizeManagement", + /// "personalizeEdge", "composableStudio", "assetManagement". + /// + /// + /// When true, strips the https:// scheme from the returned URL. + /// Useful when passing the host to an SDK that constructs its own URLs. + /// + /// The endpoint URL string for the specified service. + /// If region is empty or whitespace. + /// If region or service is not found. + /// If the registry cannot be read or is corrupt. + public static string GetContentstackEndpoint(string region, string service, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var endpoints = regionEl.Value.GetProperty("endpoints"); + if (!endpoints.TryGetProperty(service, out var urlEl)) + { + string regionId = regionEl.Value.GetProperty("id").GetString()!; + throw new KeyNotFoundException($"Service \"{service}\" not found for region \"{regionId}\""); + } + + string url = urlEl.GetString()!; + return omitHttps ? StripHttps(url) : url; + } + + /// + /// Returns all service endpoint URLs for the given region. + /// + /// Region ID or alias (case-insensitive). + /// When true, strips the https:// scheme from all returned URLs. + /// Dictionary mapping service keys to endpoint URLs. + /// If region is empty or whitespace. + /// If region is not found. + /// If the registry cannot be read or is corrupt. + public static Dictionary GetContentstackEndpoint(string region, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var result = new Dictionary(); + var endpoints = regionEl.Value.GetProperty("endpoints"); + foreach (var ep in endpoints.EnumerateObject()) + { + string url = ep.Value.GetString()!; + result[ep.Name] = omitHttps ? StripHttps(url) : url; + } + return result; + } + + /// + /// Clears the in-memory region cache. Intended for testing only — forces the + /// next call to re-read regions.json from disk or re-download from CDN. + /// + public static void ResetCache() + { + lock (_cacheLock) + { + _regionsData = null; + } + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + /// + /// Load and cache the regions registry. + /// + /// Resolution order: + /// 1. In-memory cache — zero I/O after the first call in a process + /// 2. Local file on disk — Assets/regions.json next to the DLL + /// (written by DownloadAndSave or refresh-region.cs) + /// 3. CDN download — fetches from artifacts.contentstack.com, + /// writes to disk for future calls (silent on failure) + /// + private static JsonElement[] LoadRegions() + { + lock (_cacheLock) + { + if (_regionsData != null) + return _regionsData; + + string localFile = GetLocalFilePath(); + + // Step 2 — local file on disk + string? json = ReadLocalFile(localFile); + + // Step 3 — CDN download, writes to disk so next startup skips this step + if (json == null) + json = DownloadAndSave(localFile); + + if (json == null) + throw new InvalidOperationException( + "contentstack_csharp: regions.json not found and could not be downloaded. " + + "Run 'dotnet run Scripts/refresh-region.cs' and ensure network access."); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "contentstack_csharp: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it.", ex); + } + + if (!doc.RootElement.TryGetProperty("regions", out var regionsEl) || + regionsEl.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "contentstack_csharp: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it."); + } + + var list = new List(); + foreach (var r in regionsEl.EnumerateArray()) + list.Add(r.Clone()); + + _regionsData = list.ToArray(); + return _regionsData; + } + } + + /// + /// Returns the path to regions.json next to the DLL. + /// + internal static string GetLocalFilePath() + { + string assemblyDir = Path.GetDirectoryName(typeof(Endpoint).Assembly.Location) + ?? AppContext.BaseDirectory; + return Path.Combine(assemblyDir, "Assets", "regions.json"); + } + + /// + /// Reads regions.json from disk. Returns null if the file does not exist. + /// + private static string? ReadLocalFile(string path) + { + if (!File.Exists(path)) + return null; + try + { + return File.ReadAllText(path); + } + catch + { + return null; + } + } + + /// + /// Downloads regions.json from the CDN and writes it to disk so that future + /// process startups read from the local file instead of downloading again. + /// Silent on all failures (network error, permission denied). + /// + private static string? DownloadAndSave(string dest) + { + try + { + var task = _httpClient.GetStringAsync(RegionsUrl); + task.Wait(); + string data = task.Result; + + using var doc = JsonDocument.Parse(data); + if (!doc.RootElement.TryGetProperty("regions", out _)) + return null; + + // Write to disk — next startup reads from local file (Step 2). + // Silent on PermissionError or read-only filesystem. + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, data); + } + catch { } + + return data; + } + catch + { + return null; + } + } + + /// + /// Two-pass region lookup: ID match wins over alias match. + /// Input must already be lowercased and trimmed. + /// + private static JsonElement? FindRegion(JsonElement[] regions, string normalized) + { + // Pass 1 — exact id match + foreach (var r in regions) + { + if (r.TryGetProperty("id", out var id) && + id.GetString()?.ToLowerInvariant() == normalized) + return r; + } + + // Pass 2 — alias match + foreach (var r in regions) + { + if (!r.TryGetProperty("alias", out var aliases)) continue; + foreach (var alias in aliases.EnumerateArray()) + { + if (alias.GetString()?.ToLowerInvariant() == normalized) + return r; + } + } + + return null; + } + + private static string StripHttps(string url) + { + return Regex.Replace(url, @"^https?://", string.Empty); + } + } +} diff --git a/Contentstack.Core/Internals/ContentstackRegion.cs b/Contentstack.Core/Internals/ContentstackRegion.cs index 2d8f6382..a27c8aa5 100644 --- a/Contentstack.Core/Internals/ContentstackRegion.cs +++ b/Contentstack.Core/Internals/ContentstackRegion.cs @@ -1,4 +1,6 @@ -namespace Contentstack.Core.Internals +using System.Collections.Generic; + +namespace Contentstack.Core.Internals { /// /// Contentstack region. @@ -31,7 +33,12 @@ public enum ContentstackRegion /// /// To specify AWS_AU region. /// - AU + AU, + + /// + /// To specify GCP_EU region. + /// + GCP_EU } @@ -48,6 +55,25 @@ internal enum ContentstackRegionCode gcp_na, au, - + + gcp_eu, + } + + /// + /// Maps enum values to the region ID strings + /// used by the Contentstack regions registry (artifacts.contentstack.com/regions.json). + /// + internal static class ContentstackRegionMap + { + internal static readonly Dictionary RegionIdMap = new Dictionary + { + { ContentstackRegion.US, "na" }, + { ContentstackRegion.EU, "eu" }, + { ContentstackRegion.AZURE_EU, "azure-eu" }, + { ContentstackRegion.AZURE_NA, "azure-na" }, + { ContentstackRegion.GCP_NA, "gcp-na" }, + { ContentstackRegion.AU, "au" }, + { ContentstackRegion.GCP_EU, "gcp-eu" }, + }; } } diff --git a/Scripts/refresh-region.py b/Scripts/refresh-region.py new file mode 100644 index 00000000..bc3db75b --- /dev/null +++ b/Scripts/refresh-region.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Refresh regions.json from the Contentstack CDN. +# Usage (run from your project root): +# python3 Scripts/refresh-region.py # Mac / Linux +# python Scripts/refresh-region.py # Windows + +import glob +import json +import os +import ssl +import sys +import urllib.request + +REGIONS_URL = "https://artifacts.contentstack.com/regions.json" + +print(f"Fetching {REGIONS_URL} ...") + + +def _fetch(url): + # First attempt: normal SSL verification + try: + with urllib.request.urlopen(url, timeout=30) as r: + return r.read().decode("utf-8") + except urllib.error.URLError as e: + if "CERTIFICATE_VERIFY_FAILED" not in str(e): + raise + # macOS python.org builds often lack system certs — retry without verification + print("WARNING: SSL certificate verification failed. Retrying without verification.", file=sys.stderr) + print(" To fix permanently, run: /Applications/Python*/Install\\ Certificates.command", file=sys.stderr) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(url, timeout=30, context=ctx) as r: + return r.read().decode("utf-8") + + +try: + raw = _fetch(REGIONS_URL) +except Exception as e: + print(f"ERROR: Could not download regions.json: {e}", file=sys.stderr) + sys.exit(1) + +try: + data = json.loads(raw) +except json.JSONDecodeError as e: + print(f"ERROR: Downloaded content is not valid JSON: {e}", file=sys.stderr) + sys.exit(1) + +if "regions" not in data: + print("ERROR: Downloaded JSON does not contain a 'regions' key.", file=sys.stderr) + sys.exit(1) + +region_count = len(data["regions"]) + +# Scan bin/ for every copy of the DLL (covers Debug/Release, any TFM, any nesting). +dll_pattern = os.path.join(os.getcwd(), "**", "Contentstack.Core.dll") +found = [ + os.path.dirname(dll) + for dll in glob.glob(dll_pattern, recursive=True) + if os.sep + "bin" + os.sep in dll +] + +if not found: + print("[bin] No build output found — run 'dotnet build' first, then re-run this script.") + sys.exit(1) + +for bin_dir in found: + assets_dir = os.path.join(bin_dir, "Assets") + os.makedirs(assets_dir, exist_ok=True) + dest = os.path.join(assets_dir, "regions.json") + with open(dest, "w", encoding="utf-8") as f: + f.write(raw) + print(f"[bin] Wrote {region_count} regions → {dest}") diff --git a/build/contentstack.csharp.targets b/build/contentstack.csharp.targets new file mode 100644 index 00000000..52a70638 --- /dev/null +++ b/build/contentstack.csharp.targets @@ -0,0 +1,20 @@ + + + + <_RefreshScriptDest>$(MSBuildProjectDirectory)/Scripts/refresh-region.py + <_RefreshScriptSrc>$(MSBuildThisFileDirectory)../contentFiles/cs/any/Scripts/refresh-region.py + + + + + + + + From 3f49e4b72dff101994621466115ceff191576ccb Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Sat, 20 Jun 2026 10:00:31 +0530 Subject: [PATCH 08/11] Revert "Merge branch 'development' into enhc/DX-7269" This reverts commit adaaf890a10bfe762e1baf5c1da4233cf0692a1e, reversing changes made to 9706449ad5aebbc40f86a246caf4581391c42aac. --- .cursor/rules/README.md | 5 - .github/workflows/back-merge-pr.yml | 54 - .github/workflows/check-branch.yml | 20 + .github/workflows/check-version-bump.yml | 86 - AGENTS.md | 49 - CHANGELOG.md | 26 +- .../Contentstack.Core.Tests.csproj | 7 +- .../ContentstackClientForkTests.cs | 370 ----- .../ContentstackClientResetTests.cs | 476 ------ .../ContentstackClientUnitTests.cs | 1465 +---------------- .../Helpers/ContentstackClientTestBase.cs | 424 ----- .../Helpers/TimelinePerformanceHelpers.cs | 164 -- .../Helpers/TimelineTestDataBuilder.cs | 310 ---- .../LivePreviewConfigFingerprintTests.cs | 531 ------ .../LivePreviewConfigUnitTests.cs | 304 ---- .../Mokes/TimelineMockHttpHandler.cs | 259 --- .../TimelineCacheBehaviorTests.cs | 561 ------- .../TimelineIsolationTests.cs | 540 ------ .../TimelinePreviewErrorHandlingTests.cs | 604 ------- .../Configuration/LivePreviewConfig.cs | 22 +- Contentstack.Core/ContentstackClient.cs | 157 +- Contentstack.Core/Models/Entry.cs | 71 +- Contentstack.Core/Models/Query.cs | 44 +- Directory.Build.props | 4 +- README.md | 2 - skills/README.md | 21 - skills/code-review/SKILL.md | 223 --- skills/dev-workflow/SKILL.md | 90 - skills/error-handling/SKILL.md | 298 ---- skills/models-and-serialization/SKILL.md | 248 --- skills/query-building/SKILL.md | 312 ---- skills/sdk-core-patterns/SKILL.md | 241 --- skills/testing/SKILL.md | 334 ---- snyk.json | 434 ----- 34 files changed, 109 insertions(+), 8647 deletions(-) delete mode 100644 .cursor/rules/README.md delete mode 100644 .github/workflows/back-merge-pr.yml create mode 100644 .github/workflows/check-branch.yml delete mode 100644 .github/workflows/check-version-bump.yml delete mode 100644 AGENTS.md delete mode 100644 Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs delete mode 100644 Contentstack.Core.Unit.Tests/ContentstackClientResetTests.cs delete mode 100644 Contentstack.Core.Unit.Tests/Helpers/ContentstackClientTestBase.cs delete mode 100644 Contentstack.Core.Unit.Tests/Helpers/TimelinePerformanceHelpers.cs delete mode 100644 Contentstack.Core.Unit.Tests/Helpers/TimelineTestDataBuilder.cs delete mode 100644 Contentstack.Core.Unit.Tests/LivePreviewConfigFingerprintTests.cs delete mode 100644 Contentstack.Core.Unit.Tests/Mokes/TimelineMockHttpHandler.cs delete mode 100644 Contentstack.Core.Unit.Tests/TimelineCacheBehaviorTests.cs delete mode 100644 Contentstack.Core.Unit.Tests/TimelineIsolationTests.cs delete mode 100644 Contentstack.Core.Unit.Tests/TimelinePreviewErrorHandlingTests.cs delete mode 100644 skills/README.md delete mode 100644 skills/code-review/SKILL.md delete mode 100644 skills/dev-workflow/SKILL.md delete mode 100644 skills/error-handling/SKILL.md delete mode 100644 skills/models-and-serialization/SKILL.md delete mode 100644 skills/query-building/SKILL.md delete mode 100644 skills/sdk-core-patterns/SKILL.md delete mode 100644 skills/testing/SKILL.md delete mode 100644 snyk.json diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md deleted file mode 100644 index f5c1f870..00000000 --- a/.cursor/rules/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Cursor (optional) - -**Cursor** users: start at **[AGENTS.md](../../AGENTS.md)**. All conventions live in **`skills/*/SKILL.md`**. - -This folder only points contributors to **`AGENTS.md`** so editor-specific config does not duplicate the canonical docs. diff --git a/.github/workflows/back-merge-pr.yml b/.github/workflows/back-merge-pr.yml deleted file mode 100644 index 02b378ce..00000000 --- a/.github/workflows/back-merge-pr.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Back-merge master to development - -on: - push: - branches: - - master - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - -jobs: - open-back-merge-pr: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Open back-merge PR if needed - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - BASE_BRANCH="development" - SOURCE_BRANCH="master" - - git fetch origin "$BASE_BRANCH" "$SOURCE_BRANCH" - - if ! git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then - echo "Base branch '$BASE_BRANCH' does not exist on origin; skipping." - exit 0 - fi - - SOURCE_SHA=$(git rev-parse "origin/$SOURCE_BRANCH") - BASE_SHA=$(git rev-parse "origin/$BASE_BRANCH") - - if [ "$SOURCE_SHA" = "$BASE_SHA" ]; then - echo "$SOURCE_BRANCH and $BASE_BRANCH are at the same commit; nothing to back-merge." - exit 0 - fi - - EXISTING=$(gh pr list --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --state open --json number --jq 'length') - - if [ "$EXISTING" -gt 0 ]; then - echo "An open PR from $SOURCE_BRANCH to $BASE_BRANCH already exists; skipping." - exit 0 - fi - - gh pr create --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --title "chore: back-merge $SOURCE_BRANCH into $BASE_BRANCH" --body "Automated back-merge after changes landed on \\`$SOURCE_BRANCH\\`. Review and merge to keep \\`$BASE_BRANCH\\` in sync." - - echo "Created back-merge PR $SOURCE_BRANCH -> $BASE_BRANCH." diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml new file mode 100644 index 00000000..2332f0d0 --- /dev/null +++ b/.github/workflows/check-branch.yml @@ -0,0 +1,20 @@ +name: 'Check Branch' + +on: + pull_request: + +jobs: + check_branch: + runs-on: ubuntu-latest + steps: + - name: Comment PR + if: github.base_ref == 'master' && github.head_ref != 'staging' + uses: thollander/actions-comment-pull-request@v2 + with: + message: | + We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. + - name: Check branch + if: github.base_ref == 'master' && github.head_ref != 'staging' + run: | + echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." + exit 1 diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml deleted file mode 100644 index 8e710002..00000000 --- a/.github/workflows/check-version-bump.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Check Version Bump - -on: - pull_request: - -jobs: - version-bump: - name: Version & Changelog bump - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Detect changed files and version bump - id: detect - run: | - if git rev-parse HEAD^2 >/dev/null 2>&1; then - FILES=$(git diff --name-only HEAD^1 HEAD^2) - else - FILES=$(git diff --name-only HEAD~1 HEAD) - fi - VERSION_FILES_CHANGED=false - echo "$FILES" | grep -qx 'package.json' && VERSION_FILES_CHANGED=true - echo "$FILES" | grep -qx 'CHANGELOG.md' && VERSION_FILES_CHANGED=true - echo "version_files_changed=$VERSION_FILES_CHANGED" >> $GITHUB_OUTPUT - # Only lib/, webpack/, dist/, package.json count as release-affecting; .github/ and test/ do not - CODE_CHANGED=false - echo "$FILES" | grep -qE '^lib/|^webpack/|^dist/' && CODE_CHANGED=true - echo "$FILES" | grep -qx 'package.json' && CODE_CHANGED=true - echo "code_changed=$CODE_CHANGED" >> $GITHUB_OUTPUT - - - name: Skip when only test/docs/.github changed - if: steps.detect.outputs.code_changed != 'true' - run: | - echo "No release-affecting files changed (e.g. only test/docs/.github). Skipping version-bump check." - exit 0 - - - name: Fail when version bump was missed - if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed != 'true' - run: | - echo "::error::This PR has code changes but no version bump. Please bump the version in package.json and add an entry in CHANGELOG.md." - exit 1 - - - name: Setup Node - if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - - name: Check version bump - if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' - run: | - set -e - PKG_VERSION=$(node -p "require('./package.json').version.replace(/^v/, '')") - if [ -z "$PKG_VERSION" ]; then - echo "::error::Could not read version from package.json" - exit 1 - fi - git fetch --tags --force 2>/dev/null || true - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) - if [ -z "$LATEST_TAG" ]; then - echo "No existing tags found. Skipping version-bump check (first release)." - exit 0 - fi - LATEST_VERSION="${LATEST_TAG#v}" - LATEST_VERSION="${LATEST_VERSION%%-*}" - if [ "$(printf '%s\n' "$LATEST_VERSION" "$PKG_VERSION" | sort -V | tail -1)" != "$PKG_VERSION" ]; then - echo "::error::Version bump required: package.json version ($PKG_VERSION) is not greater than latest tag ($LATEST_TAG). Please bump the version in package.json." - exit 1 - fi - if [ "$PKG_VERSION" = "$LATEST_VERSION" ]; then - echo "::error::Version bump required: package.json version ($PKG_VERSION) equals latest tag ($LATEST_TAG). Please bump the version in package.json." - exit 1 - fi - CHANGELOG_VERSION=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CHANGELOG.md | head -1) - if [ -z "$CHANGELOG_VERSION" ]; then - echo "::error::Could not find a version entry in CHANGELOG.md (expected line like '## [v1.0.0](...)')." - exit 1 - fi - if [ "$CHANGELOG_VERSION" != "$PKG_VERSION" ]; then - echo "::error::CHANGELOG version mismatch: CHANGELOG.md top version ($CHANGELOG_VERSION) does not match package.json version ($PKG_VERSION). Please add or update the CHANGELOG entry for $PKG_VERSION." - exit 1 - fi - echo "Version bump check passed: package.json and CHANGELOG.md are at $PKG_VERSION (latest tag: $LATEST_TAG)." diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index d8381ca1..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,49 +0,0 @@ -# Contentstack .NET SDK – Agent guide - -**Universal entry point** for contributors and AI agents. Each skill is documented in **`skills/*/SKILL.md`** (YAML frontmatter for agent discovery where applicable). - -## What this repo is - -| Field | Detail | -|-------|--------| -| **Name:** | [contentstack-dotnet](https://github.com/contentstack/contentstack-dotnet) | -| **Purpose:** | .NET SDK for Contentstack’s Content Delivery API (CDA)—fetch entries, assets, and run queries from .NET apps. | -| **Out of scope (if any):** | Do not bypass the SDK HTTP layer with ad-hoc `HttpClient` usage; all requests go through `HttpRequestHandler` (see `skills/sdk-core-patterns/SKILL.md`). | - -## Tech stack (at a glance) - -| Area | Details | -|------|---------| -| Language | C#; multi-targeting includes `netstandard2.0`, `net47`, `net472` (see project files under `Contentstack.Core/`). | -| Build | .NET SDK — solution [`Contentstack.Net.sln`](Contentstack.Net.sln); packages [`Contentstack.Core/`](Contentstack.Core/) (Delivery SDK), [`Contentstack.AspNetCore/`](Contentstack.AspNetCore/) (DI extensions). | -| Tests | xUnit; unit tests in [`Contentstack.Core.Unit.Tests/`](Contentstack.Core.Unit.Tests/) (no credentials); integration tests in [`Contentstack.Core.Tests/`](Contentstack.Core.Tests/) (requires `app.config` / API credentials). | -| Lint / coverage | No dedicated repo-wide lint/format CLI in CI. Security/static analysis: [CodeQL workflow](.github/workflows/codeql-analysis.yml). | -| Other | JSON: Newtonsoft.Json; package version: single source in [`Directory.Build.props`](Directory.Build.props). | - -## Commands (quick reference) - -| Command type | Command | -|--------------|---------| -| Build | `dotnet build Contentstack.Net.sln` | -| Test (unit) | `dotnet test Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj` | -| Test (integration) | `dotnet test Contentstack.Core.Tests/Contentstack.Core.Tests.csproj` (configure credentials locally) | - -CI: [`.github/workflows/unit-test.yml`](.github/workflows/unit-test.yml) restores, builds, and runs unit tests on Windows (.NET 7). Other workflows include [NuGet publish](.github/workflows/nuget-publish.yml), [branch checks](.github/workflows/check-branch.yml), [CodeQL](.github/workflows/codeql-analysis.yml), policy/SCA scans. - -## Where the documentation lives: skills - -| Skill | Path | What it covers | -|-------|------|----------------| -| Dev workflow | [`skills/dev-workflow/SKILL.md`](skills/dev-workflow/SKILL.md) | Solution layout, build/test commands, versioning, CI entry points. | -| SDK core patterns | [`skills/sdk-core-patterns/SKILL.md`](skills/sdk-core-patterns/SKILL.md) | Architecture, `ContentstackClient`, HTTP layer, DI, plugins. | -| Query building | [`skills/query-building/SKILL.md`](skills/query-building/SKILL.md) | Fluent query API, operators, pagination, sync, taxonomy. | -| Models and serialization | [`skills/models-and-serialization/SKILL.md`](skills/models-and-serialization/SKILL.md) | Entry/Asset models, JSON converters, collections. | -| Error handling | [`skills/error-handling/SKILL.md`](skills/error-handling/SKILL.md) | Exception hierarchy, `ErrorMessages`, API error parsing. | -| Testing | [`skills/testing/SKILL.md`](skills/testing/SKILL.md) | Unit vs integration tests, AutoFixture, `IntegrationTestBase`. | -| Code review | [`skills/code-review/SKILL.md`](skills/code-review/SKILL.md) | PR checklist for this SDK. | - -An index with “when to use” hints is in [`skills/README.md`](skills/README.md). - -## Using Cursor (optional) - -If you use **Cursor**, [`.cursor/rules/README.md`](.cursor/rules/README.md) only points to **`AGENTS.md`**—the same conventions as for everyone else. Canonical guidance remains in **`skills/*/SKILL.md`**. diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fd0abe..aa500fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### Version: 2.27.0 +### Version: 3.0.0-beta.2 #### Date: Jun-22-2026 ##### Feat: @@ -14,6 +14,30 @@ - Added `build/contentstack.csharp.targets` to auto-deliver `refresh-region.py` to consumer projects on first build - Added `Assets/regions.json` to `.gitignore` - Added `EndpointTest.cs` + +--- + +### Version: 3.0.0-beta.1 +#### Date: May-04-2026 + +##### Breaking Changes: +- Removed `Newtonsoft.Json` dependency; all JSON serialisation now uses `System.Text.Json` (BCL) + - `AssetJsonConverter` and `EntryJsonConverter` now implement `System.Text.Json.Serialization.JsonConverter` + - `ContentstackCollection` no longer carries `[JsonObject]`; direct `JsonSerializer` usage on this type is not supported +- Updated `contentstack.utils` from `1.0.6` to `2.0.0-beta.1` (major version bump) + +##### Feat: +- Migrated all internal JSON handling to `System.Text.Json` +- Added `ApiErrorBodyParser` for consistent API error envelope parsing +- Added `JsonNodeConversion` and `JsonObjectMerge` utilities to replace Newtonsoft equivalents +- Added `ContentstackJsonDefaults` — shared `JsonSerializerOptions` used across all custom converters + +##### Enh: +- Replaced `Console.WriteLine` with `Debug.WriteLine` in `ContentstackConvert` to suppress parse warnings from application stdout + +##### Chore: +- Updated .NET version in SCA scan CI from `7.0.x` to `10.0.x` + --- ### Version: 2.26.0 diff --git a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj index 9ad81814..4dcac804 100644 --- a/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj +++ b/Contentstack.Core.Tests/Contentstack.Core.Tests.csproj @@ -27,11 +27,6 @@ - - - - - @@ -64,4 +59,4 @@ Always - \ No newline at end of file + diff --git a/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs b/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs deleted file mode 100644 index 6dd85446..00000000 --- a/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs +++ /dev/null @@ -1,370 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Unit.Tests.Helpers; -using Contentstack.Core.Unit.Tests.Mokes; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Unit tests for ContentstackClient.Fork() method - /// Tests client forking behavior, isolation, and configuration preservation - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Fork")] - public class ContentstackClientForkTests : ContentstackClientTestBase - { - #region Positive Test Cases - - [Fact] - public void Fork_CreatesIndependentClientInstance() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - Assert.NotNull(forkedClient); - AssertClientsAreIndependent(parentClient, forkedClient); - } - - [Fact] - public void Fork_PreservesBaseConfiguration() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - AssertConfigurationPreserved(parentClient, forkedClient); - } - - [Fact] - public void Fork_PreservesCustomHeaders() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var customHeaderKey = "X-Custom-Header"; - var customHeaderValue = _fixture.Create(); - - parentClient.SetHeader(customHeaderKey, customHeaderValue); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - // Verify custom header is preserved in forked client - var parentHeaders = GetInternalField>(parentClient, "_LocalHeaders"); - var forkedHeaders = GetInternalField>(forkedClient, "_LocalHeaders"); - - Assert.True(parentHeaders.ContainsKey(customHeaderKey)); - Assert.True(forkedHeaders.ContainsKey(customHeaderKey)); - Assert.Equal(parentHeaders[customHeaderKey], forkedHeaders[customHeaderKey]); - } - - [Fact] - public void Fork_CopiesContentTypeUidHints() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var contentTypeUid = _fixture.Create(); - var entryUid = _fixture.Create(); - - // Set content type and entry hints on parent - SetInternalField(parentClient, "currentContenttypeUid", contentTypeUid); - SetInternalField(parentClient, "currentEntryUid", entryUid); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - var forkedContentTypeUid = GetInternalField(forkedClient, "currentContenttypeUid"); - var forkedEntryUid = GetInternalField(forkedClient, "currentEntryUid"); - - Assert.Equal(contentTypeUid, forkedContentTypeUid); - Assert.Equal(entryUid, forkedEntryUid); - } - - [Fact] - public void Fork_IndependentLivePreviewConfig() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var parentConfig = parentClient.GetLivePreviewConfig(); - parentConfig.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - - // Act - var forkedClient = parentClient.Fork(); - var forkedConfig = forkedClient.GetLivePreviewConfig(); - - // Assert - Configs are independent instances - Assert.NotSame(parentConfig, forkedConfig); - - // Assert - Initial values are copied - Assert.Equal(parentConfig.PreviewTimestamp, forkedConfig.PreviewTimestamp); - Assert.Equal(parentConfig.ReleaseId, forkedConfig.ReleaseId); - Assert.Equal(parentConfig.Enable, forkedConfig.Enable); - Assert.Equal(parentConfig.Host, forkedConfig.Host); - Assert.Equal(parentConfig.ManagementToken, forkedConfig.ManagementToken); - } - - [Fact] - public void Fork_SharedConfigurationReference() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var parentConfig = parentClient.GetLivePreviewConfig(); - parentConfig.PreviewResponse = CreateMockPreviewResponse(); - - // Act - var forkedClient = parentClient.Fork(); - var forkedConfig = forkedClient.GetLivePreviewConfig(); - - // Assert - PreviewResponse is shared reference (memory efficient) - Assert.Same(parentConfig.PreviewResponse, forkedConfig.PreviewResponse); - } - - [Fact] - public void Fork_MultipleLevels_IndependentContexts() - { - // Arrange - var level1Client = CreateClientWithTimeline(); - level1Client.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - - // Act - var level2Client = level1Client.Fork(); - level2Client.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T12:00:00.000Z"; - - var level3Client = level2Client.Fork(); - level3Client.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:00:00.000Z"; - - // Assert - Each level maintains its own timeline - Assert.Equal("2024-11-29T10:00:00.000Z", level1Client.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-11-29T12:00:00.000Z", level2Client.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-11-29T14:00:00.000Z", level3Client.GetLivePreviewConfig().PreviewTimestamp); - - // Assert - All are independent instances - AssertClientsAreIndependent(level1Client, level2Client); - AssertClientsAreIndependent(level2Client, level3Client); - AssertClientsAreIndependent(level1Client, level3Client); - } - - [Fact] - public void Fork_ParallelModifications_IsolatedChanges() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - - // Act - Modify each fork independently - fork1.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T08:00:00.000Z"; - fork1.GetLivePreviewConfig().ReleaseId = "fork1_release"; - - fork2.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T16:00:00.000Z"; - fork2.GetLivePreviewConfig().ReleaseId = "fork2_release"; - - // Assert - Changes are isolated - Assert.Equal("2024-11-29T08:00:00.000Z", fork1.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("fork1_release", fork1.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T16:00:00.000Z", fork2.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("fork2_release", fork2.GetLivePreviewConfig().ReleaseId); - - // Parent client should maintain its original state - Assert.Equal("2024-11-29T14:30:00.000Z", parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("test_release_123", parentClient.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void Fork_PreservesPlugins() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview(); - parentClient.Plugins.Add(mockHandler); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - Plugins collection exists but fork doesn't share plugin instances - Assert.NotNull(forkedClient.Plugins); - Assert.Empty(forkedClient.Plugins); // Fork starts with empty plugins (isolated) - } - - [Fact] - public async Task Fork_IndependentLivePreviewOperations() - { - // Arrange - var parentClient = CreateClientWithLivePreview(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - - // Set up different timeline contexts - var query1 = CreateLivePreviewQuery(previewTimestamp: "2024-11-29T08:00:00.000Z"); - var query2 = CreateLivePreviewQuery(previewTimestamp: "2024-11-29T16:00:00.000Z"); - - // Act - await fork1.LivePreviewQueryAsync(query1); - await fork2.LivePreviewQueryAsync(query2); - - // Assert - Each fork maintains its own timeline context - Assert.Equal("2024-11-29T08:00:00.000Z", fork1.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-11-29T16:00:00.000Z", fork2.GetLivePreviewConfig().PreviewTimestamp); - - // Parent should be unaffected - Assert.Null(parentClient.GetLivePreviewConfig().PreviewTimestamp); - } - - #endregion - - #region Negative Test Cases - - [Fact] - public void Fork_WithNullLivePreviewConfig_HandlesGracefully() - { - // Arrange - var parentClient = CreateClient(); // Client without LivePreview - SetInternalProperty(parentClient, "LivePreviewConfig", null); - - // Act & Assert - Should not throw - var forkedClient = parentClient.Fork(); - - Assert.NotNull(forkedClient); - // Verify the fork handles null config appropriately - var forkedConfig = forkedClient.GetLivePreviewConfig(); - Assert.NotNull(forkedConfig); // Should create a new config if parent was null - } - - [Fact] - public void Fork_WithCorruptedConfiguration_CreatesValidFork() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var config = parentClient.GetLivePreviewConfig(); - - // Corrupt some configuration properties - config.Host = null; - config.PreviewTimestamp = "invalid-timestamp-format"; - - // Act & Assert - Should not throw - var forkedClient = parentClient.Fork(); - var forkedConfig = forkedClient.GetLivePreviewConfig(); - - Assert.NotNull(forkedClient); - Assert.NotNull(forkedConfig); - Assert.Equal("invalid-timestamp-format", forkedConfig.PreviewTimestamp); // Corruption is copied but doesn't break fork - } - - [Fact] - public void Fork_AfterParentModification_IsolatesChanges() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var originalTimestamp = parentClient.GetLivePreviewConfig().PreviewTimestamp; - - // Act - Create fork, then modify parent - var forkedClient = parentClient.Fork(); - parentClient.GetLivePreviewConfig().PreviewTimestamp = "2024-12-01T00:00:00.000Z"; - - // Assert - Fork maintains original timestamp - Assert.Equal(originalTimestamp, forkedClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-12-01T00:00:00.000Z", parentClient.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public void Fork_WithLargeNumberOfForks_MaintainsPerformance() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var numberOfForks = 1000; - var forks = new ContentstackClient[numberOfForks]; - - // Act - Measure fork creation time - var startTime = DateTime.UtcNow; - - for (int i = 0; i < numberOfForks; i++) - { - forks[i] = parentClient.Fork(); - forks[i].GetLivePreviewConfig().PreviewTimestamp = $"2024-11-{(i % 12) + 1:D2}-01T00:00:00.000Z"; - } - - var duration = DateTime.UtcNow - startTime; - - // Assert - Fork creation should be fast (under 1 second for 1000 forks) - Assert.True(duration.TotalSeconds < 1.0, $"Fork creation took {duration.TotalMilliseconds}ms for {numberOfForks} forks"); - - // Verify all forks are independent - for (int i = 0; i < Math.Min(10, numberOfForks); i++) // Check first 10 for performance - { - AssertClientsAreIndependent(parentClient, forks[i]); - } - } - - #endregion - - #region Edge Cases - - [Fact] - public void Fork_WithEmptyHeaders_HandlesCorrectly() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - SetInternalField(parentClient, "_LocalHeaders", new Dictionary()); - - // Act - var forkedClient = parentClient.Fork(); - - // Assert - Assert.NotNull(forkedClient); - var forkedHeaders = GetInternalField>(forkedClient, "_LocalHeaders"); - Assert.NotNull(forkedHeaders); - } - - [Fact] - public void Fork_WithNullHeaders_HandlesCorrectly() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - SetInternalField(parentClient, "_LocalHeaders", null); - - // Act & Assert - Should not throw - var forkedClient = parentClient.Fork(); - Assert.NotNull(forkedClient); - } - - [Fact] - public void Fork_RecursiveForkModification_MaintainsIsolation() - { - // Arrange - var level1 = CreateClientWithTimeline(); - level1.GetLivePreviewConfig().ReleaseId = "level1_release"; - - var level2 = level1.Fork(); - level2.GetLivePreviewConfig().ReleaseId = "level2_release"; - - var level3 = level2.Fork(); - level3.GetLivePreviewConfig().ReleaseId = "level3_release"; - - // Act - Modify level2 after level3 is created - level2.GetLivePreviewConfig().PreviewTimestamp = "2024-11-30T00:00:00.000Z"; - - // Assert - Level3 should not be affected by level2 changes - Assert.Equal("level1_release", level1.GetLivePreviewConfig().ReleaseId); - Assert.Equal("level2_release", level2.GetLivePreviewConfig().ReleaseId); - Assert.Equal("level3_release", level3.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-30T00:00:00.000Z", level2.GetLivePreviewConfig().PreviewTimestamp); - Assert.NotEqual("2024-11-30T00:00:00.000Z", level3.GetLivePreviewConfig().PreviewTimestamp); - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/ContentstackClientResetTests.cs b/Contentstack.Core.Unit.Tests/ContentstackClientResetTests.cs deleted file mode 100644 index 0c60767a..00000000 --- a/Contentstack.Core.Unit.Tests/ContentstackClientResetTests.cs +++ /dev/null @@ -1,476 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Unit.Tests.Helpers; -using Contentstack.Core.Unit.Tests.Mokes; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Unit tests for ContentstackClient.ResetLivePreview() method - /// Tests timeline state clearing, configuration preservation, and edge cases - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Reset")] - public class ContentstackClientResetTests : ContentstackClientTestBase - { - #region Positive Test Cases - - [Fact] - public void ResetLivePreview_ClearsTimelineProperties() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set timeline properties - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "test_release_123"; - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - } - - [Fact] - public void ResetLivePreview_ClearsPreviewResponse() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - config.PreviewResponse = CreateMockPreviewResponse(); - - // Verify response is set - Assert.NotNull(config.PreviewResponse); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(config.PreviewResponse); - } - - [Fact] - public void ResetLivePreview_ClearsFingerprintProperties() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set fingerprint properties - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "fingerprint_timestamp"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "fingerprint_release"); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "fingerprint_hash"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - } - - [Fact] - public void ResetLivePreview_ClearsContentTypeContext() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set content type context - SetInternalProperty(config, "ContentTypeUID", "test_content_type"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(GetInternalProperty(config, "ContentTypeUID")); - } - - [Fact] - public void ResetLivePreview_ClearsEntryContext() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set entry context - SetInternalProperty(config, "EntryUID", "test_entry"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(GetInternalProperty(config, "EntryUID")); - } - - [Fact] - public void ResetLivePreview_ClearsLivePreviewHash() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set live preview hash - SetInternalProperty(config, "LivePreview", "test_hash_123"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(GetInternalProperty(config, "LivePreview")); - } - - [Fact] - public void ResetLivePreview_PreservesBaseConfiguration() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - var originalEnable = config.Enable; - var originalHost = config.Host; - var originalManagementToken = config.ManagementToken; - var originalPreviewToken = config.PreviewToken; - - // Act - client.ResetLivePreview(); - - // Assert - Base configuration should be preserved - Assert.Equal(originalEnable, config.Enable); - Assert.Equal(originalHost, config.Host); - Assert.Equal(originalManagementToken, config.ManagementToken); - Assert.Equal(originalPreviewToken, config.PreviewToken); - } - - [Fact] - public void ResetLivePreview_PreservesClientConfiguration() - { - // Arrange - var client = CreateClientWithTimeline(); - - var originalApiKey = client.GetApplicationKey(); - var originalAccessToken = client.GetAccessToken(); - var originalEnvironment = client.GetEnvironment(); - var originalVersion = client.GetVersion(); - - // Act - client.ResetLivePreview(); - - // Assert - Client configuration should be preserved - Assert.Equal(originalApiKey, client.GetApplicationKey()); - Assert.Equal(originalAccessToken, client.GetAccessToken()); - Assert.Equal(originalEnvironment, client.GetEnvironment()); - Assert.Equal(originalVersion, client.GetVersion()); - } - - [Fact] - public void ResetLivePreview_MultipleCallsIdempotent() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set up timeline state - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "test_release"; - config.PreviewResponse = CreateMockPreviewResponse(); - - // Act - Multiple resets - client.ResetLivePreview(); - client.ResetLivePreview(); - client.ResetLivePreview(); - - // Assert - Should be safe to call multiple times - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewResponse); - } - - [Fact] - public void ResetLivePreview_AfterComplexTimelineOperations() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set up complex timeline state - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "complex_release_123"; - config.PreviewResponse = JObject.Parse(@"{ - ""entry"": { - ""uid"": ""complex_entry"", - ""title"": ""Complex Test Entry"", - ""nested"": { - ""deep"": { - ""structure"": ""value"" - } - }, - ""array"": [1, 2, 3, 4, 5] - } - }"); - - SetInternalProperty(config, "ContentTypeUID", "complex_ct"); - SetInternalProperty(config, "EntryUID", "complex_entry"); - SetInternalProperty(config, "LivePreview", "complex_hash"); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "complex_timestamp"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "complex_release"); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "complex_hash"); - - // Act - client.ResetLivePreview(); - - // Assert - All complex state cleared - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewResponse); - Assert.Null(GetInternalProperty(config, "ContentTypeUID")); - Assert.Null(GetInternalProperty(config, "EntryUID")); - Assert.Null(GetInternalProperty(config, "LivePreview")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - } - - [Fact] - public async Task ResetLivePreview_AfterLivePreviewQuery_ClearsAllState() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery( - contentTypeUid: "reset_test_ct", - entryUid: "reset_test_entry", - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "reset_test_release" - ); - - // Execute live preview query to set up state - await client.LivePreviewQueryAsync(query); - - var config = client.GetLivePreviewConfig(); - - // Verify state is set - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); - Assert.Equal("reset_test_release", config.ReleaseId); - - // Act - client.ResetLivePreview(); - - // Assert - All state cleared - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewResponse); - Assert.Null(GetInternalProperty(config, "ContentTypeUID")); - Assert.Null(GetInternalProperty(config, "EntryUID")); - Assert.Null(GetInternalProperty(config, "LivePreview")); - } - - [Fact] - public void ResetLivePreview_DoesNotAffectForkedClients() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var forkedClient = parentClient.Fork(); - - // Set different timeline states - parentClient.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - forkedClient.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:00:00.000Z"; - - // Act - Reset parent client only - parentClient.ResetLivePreview(); - - // Assert - Parent is reset but fork is unaffected - Assert.Null(parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-11-29T14:00:00.000Z", forkedClient.GetLivePreviewConfig().PreviewTimestamp); - } - - #endregion - - #region Negative Test Cases - - [Fact] - public void ResetLivePreview_WithNullConfig_HandlesGracefully() - { - // Arrange - var client = CreateClient(); - SetInternalProperty(client, "LivePreviewConfig", null); - - // Act & Assert - Should not throw - var exception = Record.Exception(() => client.ResetLivePreview()); - Assert.Null(exception); - } - - [Fact] - public void ResetLivePreview_DisabledLivePreview_NoException() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: false); - - // Act & Assert - Should not throw - var exception = Record.Exception(() => client.ResetLivePreview()); - Assert.Null(exception); - } - - [Fact] - public void ResetLivePreview_AlreadyClearedState_HandlesCorrectly() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Clear all state first - config.PreviewTimestamp = null; - config.ReleaseId = null; - config.PreviewResponse = null; - SetInternalProperty(config, "ContentTypeUID", null); - SetInternalProperty(config, "EntryUID", null); - SetInternalProperty(config, "LivePreview", null); - - // Act & Assert - Should not throw with already cleared state - var exception = Record.Exception(() => client.ResetLivePreview()); - Assert.Null(exception); - - // Verify state remains cleared - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewResponse); - } - - [Fact] - public void ResetLivePreview_WithCorruptedState_HandlesGracefully() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Create corrupted state - config.PreviewTimestamp = "invalid-timestamp-format"; - config.PreviewResponse = JObject.Parse("{}"); // Empty invalid response - - // Act & Assert - Should not throw with corrupted state - var exception = Record.Exception(() => client.ResetLivePreview()); - Assert.Null(exception); - - // Assert - Corrupted state is cleared - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.PreviewResponse); - } - - #endregion - - #region Performance and Edge Cases - - [Fact] - public void ResetLivePreview_Performance_FastExecution() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set up state to reset - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "perf_test_release"; - config.PreviewResponse = CreateMockPreviewResponse(); - - var iterations = 1000; - var startTime = DateTime.UtcNow; - - // Act - Multiple resets to test performance - for (int i = 0; i < iterations; i++) - { - // Set some state - config.PreviewTimestamp = $"2024-11-{(i % 12) + 1:D2}-01T00:00:00.000Z"; - config.ReleaseId = $"perf_release_{i}"; - - // Reset - client.ResetLivePreview(); - } - - var duration = DateTime.UtcNow - startTime; - - // Assert - Should be very fast (under 100ms for 1000 resets) - Assert.True(duration.TotalMilliseconds < 100, - $"ResetLivePreview took {duration.TotalMilliseconds}ms for {iterations} operations"); - } - - [Fact] - public void ResetLivePreview_ConcurrentCalls_ThreadSafe() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - - var tasks = new Task[10]; - - // Act - Concurrent reset calls - for (int i = 0; i < tasks.Length; i++) - { - tasks[i] = Task.Run(() => - { - try - { - client.ResetLivePreview(); - } - catch (Exception ex) - { - // Should not throw - throw new Exception($"Concurrent reset failed: {ex.Message}", ex); - } - }); - } - - // Assert - All tasks should complete without exception - var exception = Record.Exception(() => Task.WaitAll(tasks)); - Assert.Null(exception); - - // Final state should be cleared - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewResponse); - } - - [Fact] - public void ResetLivePreview_MemoryEfficiency_ReleasesReferences() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Create large objects to test memory release - var largeResponse = JObject.Parse(@"{ - ""entry"": { - ""large_field"": """ + new string('x', 10000) + @""", - ""another_large_field"": """ + new string('y', 10000) + @""" - } - }"); - - config.PreviewResponse = largeResponse; - - // Act - client.ResetLivePreview(); - - // Force garbage collection - largeResponse = null; - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - // Assert - References should be cleared - Assert.Null(config.PreviewResponse); - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs b/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs index 6fef1d24..f5073b8b 100644 --- a/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs @@ -8,24 +8,31 @@ using Contentstack.Core.Configuration; using Contentstack.Core.Internals; using Contentstack.Core.Models; -using Contentstack.Core.Unit.Tests.Helpers; using Contentstack.Core.Unit.Tests.Mokes; using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; using Xunit; namespace Contentstack.Core.Unit.Tests { /// - /// Comprehensive unit tests for ContentstackClient class - /// Includes Timeline Preview functionality: Fork(), ResetLivePreview(), LivePreviewQueryAsync() - /// Uses mocks and AutoFixture, no real API calls + /// Unit tests for ContentstackClient class - uses mocks and AutoFixture, no real API calls + /// Follows Management SDK pattern /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Unit")] - [Trait("Category", "Fast")] - public class ContentstackClientUnitTests : ContentstackClientTestBase + public class ContentstackClientUnitTests { + private readonly IFixture _fixture = new Fixture(); + + private ContentstackClient CreateClient(string environment = null, string apiKey = null, string deliveryToken = null, string version = null) + { + var options = new ContentstackOptions() + { + ApiKey = apiKey ?? _fixture.Create(), + DeliveryToken = deliveryToken ?? _fixture.Create(), + Environment = environment ?? _fixture.Create(), + Version = version + }; + return new ContentstackClient(new OptionsWrapper(options)); + } [Fact] public void GetEnvironment_ReturnsEnvironment() @@ -942,1445 +949,11 @@ public async Task LivePreviewQueryAsync_ClearsLivePreviewConfig() await client.LivePreviewQueryAsync(query); // Assert - Assert.Null(client.LivePreviewConfig.LivePreview); - Assert.Null(client.LivePreviewConfig.PreviewTimestamp); - Assert.Null(client.LivePreviewConfig.ReleaseId); - } - - #region Timeline Preview - Fork() Comprehensive Tests - - [Fact] - public void Fork_CreatesNewInstance_NotSameReference() - { - // Arrange - var client = CreateClient(); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.NotSame(client, forkedClient); - } - - [Fact] - public void Fork_CreatesIndependentClient_DifferentIdentity() - { - // Arrange - var client = CreateClient(); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.NotSame(client, forkedClient); - Assert.NotEqual(client.GetHashCode(), forkedClient.GetHashCode()); - } - - [Fact] - public void Fork_PreservesApiKey_ExactMatch() - { - // Arrange - var apiKey = "test_api_key"; - var client = CreateClient(apiKey: apiKey); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetApplicationKey(), forkedClient.GetApplicationKey()); - } - - [Fact] - public void Fork_PreservesAccessToken_ExactMatch() - { - // Arrange - var client = CreateClient(); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetAccessToken(), forkedClient.GetAccessToken()); - } - - [Fact] - public void Fork_PreservesEnvironment_ExactMatch() - { - // Arrange - var environment = "test_environment"; - var client = CreateClient(environment: environment); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetEnvironment(), forkedClient.GetEnvironment()); - } - - [Fact] - public void Fork_PreservesHost_ExactMatch() - { - // Arrange - var client = CreateClient(); - // For testing purposes, we verify that both client and fork return consistent host values - - // Act - var forkedClient = client.Fork(); - var originalHost = GetHost(client); - var forkedHost = GetHost(forkedClient); - - // Assert - Assert.Equal(originalHost, forkedHost); - Assert.NotNull(originalHost); // Ensure it's not null - } - - [Fact] - public void Fork_PreservesTimeout_ExactMatch() - { - // Arrange - var client = CreateClient(); - SetTimeout(client, 30000); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(GetTimeout(client), GetTimeout(forkedClient)); - } - - [Fact] - public void Fork_PreservesRegion_ExactMatch() - { - // Arrange - var client = CreateClient(); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(GetRegion(client), GetRegion(forkedClient)); - } - - [Fact] - public void Fork_PreservesVersion_ExactMatch() - { - // Arrange - var version = "v3"; - var client = CreateClient(version: version); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetVersion(), forkedClient.GetVersion()); - } - - [Fact] - public void Fork_PreservesBranch_ExactMatch() - { - // Arrange - var client = CreateClient(); - SetBranch(client, "test_branch"); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(GetBranch(client), GetBranch(forkedClient)); - } - - [Fact] - public void Fork_ClonesLivePreviewConfig_NotSameReference() - { - // Arrange - var client = CreateClientWithLivePreview(); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.NotSame(client.GetLivePreviewConfig(), forkedClient.GetLivePreviewConfig()); - } - - [Fact] - public void Fork_PreservesLivePreviewEnable_ExactMatch() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: true); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().Enable, forkedClient.GetLivePreviewConfig().Enable); - } - - [Fact] - public void Fork_PreservesLivePreviewHost_ExactMatch() - { - // Arrange - var host = "custom.preview.host.com"; - var client = CreateClientWithLivePreview(host: host); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().Host, forkedClient.GetLivePreviewConfig().Host); - } - - [Fact] - public void Fork_PreservesManagementToken_ExactMatch() - { - // Arrange - var client = CreateClientWithLivePreview(); - client.GetLivePreviewConfig().ManagementToken = "test_mgmt_token"; - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().ManagementToken, forkedClient.GetLivePreviewConfig().ManagementToken); - } - - [Fact] - public void Fork_PreservesPreviewToken_ExactMatch() - { - // Arrange - var client = CreateClientWithLivePreview(); - client.GetLivePreviewConfig().PreviewToken = "test_preview_token"; - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().PreviewToken, forkedClient.GetLivePreviewConfig().PreviewToken); - } - - [Fact] - public void Fork_PreservesReleaseId_ExactMatch() - { - // Arrange - var client = CreateClientWithTimeline(releaseId: "test_release_123"); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().ReleaseId, forkedClient.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void Fork_PreservesPreviewTimestamp_ExactMatch() - { - // Arrange - var timestamp = "2024-11-29T14:30:00.000Z"; - var client = CreateClientWithTimeline(timestamp: timestamp); - - // Act - var forkedClient = client.Fork(); - - // Assert - Assert.Equal(client.GetLivePreviewConfig().PreviewTimestamp, forkedClient.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public void Fork_PreservesLivePreview_ExactMatch() - { - // Arrange - var hash = "test_hash_456"; - var client = CreateClientWithTimeline(hash: hash); - - // Act - var forkedClient = client.Fork(); - - // Assert - var parentProperty = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var forkedProperty = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var parentValue = parentProperty?.GetValue(client.GetLivePreviewConfig()); - var forkedValue = forkedProperty?.GetValue(forkedClient.GetLivePreviewConfig()); - - Assert.Equal(parentValue, forkedValue); - } - - [Fact] - public void Fork_PreservesContentTypeUID_ExactMatch() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - var forkedClient = client.Fork(); - - // Assert - var parentProperty = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var forkedProperty = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var parentValue = parentProperty?.GetValue(client.GetLivePreviewConfig()); - var forkedValue = forkedProperty?.GetValue(forkedClient.GetLivePreviewConfig()); - - Assert.Equal(parentValue, forkedValue); - } - - [Fact] - public void Fork_PreservesEntryUID_ExactMatch() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - var forkedClient = client.Fork(); - - // Assert - var parentProperty = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var forkedProperty = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var parentValue = parentProperty?.GetValue(client.GetLivePreviewConfig()); - var forkedValue = forkedProperty?.GetValue(forkedClient.GetLivePreviewConfig()); - - Assert.Equal(parentValue, forkedValue); - } - - [Fact] - public void Fork_PreservesPreviewResponse_SameReference() - { - // Arrange - var client = CreateClientWithLivePreview(); - var previewResponse = TimelineMockHelpers.CreateMockLivePreviewResponse(); - client.GetLivePreviewConfig().PreviewResponse = Newtonsoft.Json.Linq.JObject.Parse(previewResponse); - - // Act - var forkedClient = client.Fork(); - - // Assert - PreviewResponse should be shared reference for memory efficiency - Assert.Same(client.GetLivePreviewConfig().PreviewResponse, forkedClient.GetLivePreviewConfig().PreviewResponse); - } - - [Fact] - public void Fork_PreservesAllFingerprints_ExactMatch() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set fingerprints using reflection - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "fingerprint_timestamp"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "fingerprint_release"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "fingerprint_hash"); - - // Act - var forkedClient = client.Fork(); - var forkedConfig = forkedClient.GetLivePreviewConfig(); - - // Assert - var parentTimestamp = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config); - var forkedTimestamp = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(forkedConfig); - - Assert.Equal(parentTimestamp, forkedTimestamp); - } - - #endregion - - #region Timeline Preview - Fork() Isolation Tests - - [Fact] - public void Fork_CopiesAllHeaders_ExactMatch() - { - // Arrange - var client = CreateClient(); - client.SetHeader("custom-header", "test-value"); - client.SetHeader("another-header", "another-value"); - - // Act - var forkedClient = client.Fork(); - - // Assert - Headers should be copied (we can't directly access them, so test by setting different values) - forkedClient.SetHeader("custom-header", "modified-value"); - - // Parent should still have original value (test via reflection or observable behavior) - // This is a behavioral test - the key is that they're independent - Assert.NotSame(client, forkedClient); - } - - [Fact] - public void Fork_ModifyParentHeaders_DoesNotAffectForked() - { - // Arrange - var client = CreateClient(); - client.SetHeader("test-header", "original-value"); - var forkedClient = client.Fork(); - - // Act - client.SetHeader("test-header", "modified-value"); - - // Assert - This tests isolation behavior - Assert.NotSame(client, forkedClient); - } - - [Fact] - public void Fork_ModifyForkedHeaders_DoesNotAffectParent() - { - // Arrange - var client = CreateClient(); - client.SetHeader("test-header", "original-value"); - var forkedClient = client.Fork(); - - // Act - forkedClient.SetHeader("test-header", "modified-value"); - forkedClient.SetHeader("new-header", "new-value"); - - // Assert - This tests isolation behavior - Assert.NotSame(client, forkedClient); + Assert.Null(client.LivePreviewConfig.LivePreview); + Assert.Null(client.LivePreviewConfig.PreviewTimestamp); + Assert.Null(client.LivePreviewConfig.ReleaseId); } - [Fact] - public void Fork_ModifyParentLivePreviewConfig_DoesNotAffectForked() - { - // Arrange - var client = CreateClientWithTimeline(); - var forkedClient = client.Fork(); - - // Act - Modify parent config - client.GetLivePreviewConfig().ReleaseId = "modified_release"; - client.GetLivePreviewConfig().PreviewTimestamp = "modified_timestamp"; - - // Assert - Forked config should be unaffected - Assert.NotEqual(client.GetLivePreviewConfig().ReleaseId, forkedClient.GetLivePreviewConfig().ReleaseId); - Assert.NotEqual(client.GetLivePreviewConfig().PreviewTimestamp, forkedClient.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public void Fork_ModifyForkedLivePreviewConfig_DoesNotAffectParent() - { - // Arrange - var client = CreateClientWithTimeline(); - var originalReleaseId = client.GetLivePreviewConfig().ReleaseId; - var originalTimestamp = client.GetLivePreviewConfig().PreviewTimestamp; - var forkedClient = client.Fork(); - - // Act - Modify forked config - forkedClient.GetLivePreviewConfig().ReleaseId = "forked_release"; - forkedClient.GetLivePreviewConfig().PreviewTimestamp = "forked_timestamp"; - - // Assert - Parent config should be unaffected - Assert.Equal(originalReleaseId, client.GetLivePreviewConfig().ReleaseId); - Assert.Equal(originalTimestamp, client.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public void Fork_ModifyParentPreviewResponse_AffectsBothDueToSharedReference() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockResponse = Newtonsoft.Json.Linq.JObject.Parse(TimelineMockHelpers.CreateMockLivePreviewResponse()); - client.GetLivePreviewConfig().PreviewResponse = mockResponse; - var forkedClient = client.Fork(); - - // Act - Modify the shared JObject - mockResponse["modified"] = "test"; - - // Assert - Both should see the change since it's a shared reference - Assert.Same(client.GetLivePreviewConfig().PreviewResponse, forkedClient.GetLivePreviewConfig().PreviewResponse); - Assert.Equal("test", client.GetLivePreviewConfig().PreviewResponse["modified"]?.ToString()); - Assert.Equal("test", forkedClient.GetLivePreviewConfig().PreviewResponse["modified"]?.ToString()); - } - - [Fact] - public void Fork_ParentResetLivePreview_DoesNotAffectForked() - { - // Arrange - var client = CreateClientWithTimeline(); - var forkedClient = client.Fork(); - var forkedReleaseId = forkedClient.GetLivePreviewConfig().ReleaseId; - - // Act - Reset parent's live preview - client.ResetLivePreview(); - - // Assert - Forked client should be unaffected - Assert.Null(client.GetLivePreviewConfig().ReleaseId); - Assert.Equal(forkedReleaseId, forkedClient.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void Fork_ForkedResetLivePreview_DoesNotAffectParent() - { - // Arrange - var client = CreateClientWithTimeline(); - var parentReleaseId = client.GetLivePreviewConfig().ReleaseId; - var forkedClient = client.Fork(); - - // Act - Reset forked client's live preview - forkedClient.ResetLivePreview(); - - // Assert - Parent client should be unaffected - Assert.Null(forkedClient.GetLivePreviewConfig().ReleaseId); - Assert.Equal(parentReleaseId, client.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void Fork_WithNullLivePreviewConfig_CreatesDefault() - { - // Arrange - var client = CreateClient(); // No LivePreview config - - // Act - var forkedClient = client.Fork(); - - // Assert - Should not throw and should have default config - Assert.NotNull(forkedClient.GetLivePreviewConfig()); - Assert.False(forkedClient.GetLivePreviewConfig().Enable); - } - - [Fact] - public void Fork_MultipleForks_AllIndependent() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - var fork1 = client.Fork(); - var fork2 = client.Fork(); - var fork3 = fork1.Fork(); // Fork of a fork - - // Assert - All should be independent - Assert.NotSame(client, fork1); - Assert.NotSame(client, fork2); - Assert.NotSame(client, fork3); - Assert.NotSame(fork1, fork2); - Assert.NotSame(fork1, fork3); - Assert.NotSame(fork2, fork3); - - // Modify one - others should be unaffected - fork1.GetLivePreviewConfig().ReleaseId = "fork1_modified"; - - Assert.NotEqual(fork1.GetLivePreviewConfig().ReleaseId, client.GetLivePreviewConfig().ReleaseId); - Assert.NotEqual(fork1.GetLivePreviewConfig().ReleaseId, fork2.GetLivePreviewConfig().ReleaseId); - Assert.NotEqual(fork1.GetLivePreviewConfig().ReleaseId, fork3.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void Fork_NestedForks_MaintainIndependence() - { - // Arrange - var grandparent = CreateClientWithTimeline(releaseId: "grandparent_release"); - var parent = grandparent.Fork(); - parent.GetLivePreviewConfig().ReleaseId = "parent_release"; - var child = parent.Fork(); - child.GetLivePreviewConfig().ReleaseId = "child_release"; - - // Act & Assert - Each should maintain its own state - Assert.Equal("grandparent_release", grandparent.GetLivePreviewConfig().ReleaseId); - Assert.Equal("parent_release", parent.GetLivePreviewConfig().ReleaseId); - Assert.Equal("child_release", child.GetLivePreviewConfig().ReleaseId); - - // Modify child - should not affect parent or grandparent - child.GetLivePreviewConfig().ReleaseId = "modified_child"; - - Assert.Equal("grandparent_release", grandparent.GetLivePreviewConfig().ReleaseId); - Assert.Equal("parent_release", parent.GetLivePreviewConfig().ReleaseId); - Assert.Equal("modified_child", child.GetLivePreviewConfig().ReleaseId); - } - - #endregion - - #region Timeline Preview - ResetLivePreview() Complete State Management - - [Fact] - public void ResetLivePreview_ClearsLivePreview_SetsNull() - { - // Arrange - var client = CreateClientWithTimeline(hash: "test_hash"); - - // Act - client.ResetLivePreview(); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_ClearsReleaseId_SetsNull() - { - // Arrange - var client = CreateClientWithTimeline(releaseId: "test_release"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(client.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void ResetLivePreview_ClearsPreviewTimestamp_SetsNull() - { - // Arrange - var client = CreateClientWithTimeline(timestamp: "2024-11-29T14:30:00.000Z"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(client.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public void ResetLivePreview_ClearsContentTypeUID_SetsNull() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - client.ResetLivePreview(); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_ClearsEntryUID_SetsNull() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - client.ResetLivePreview(); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_ClearsPreviewResponse_SetsNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - client.GetLivePreviewConfig().PreviewResponse = Newtonsoft.Json.Linq.JObject.Parse("{\"test\": \"value\"}"); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Null(client.GetLivePreviewConfig().PreviewResponse); - } - - [Fact] - public void ResetLivePreview_ClearsPreviewTimestampFingerprint_SetsNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - var property = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - property?.SetValue(client.GetLivePreviewConfig(), "test_fingerprint"); - - // Act - client.ResetLivePreview(); - - // Assert - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_ClearsReleaseIdFingerprint_SetsNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - var property = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - property?.SetValue(client.GetLivePreviewConfig(), "test_fingerprint"); - - // Act - client.ResetLivePreview(); - - // Assert - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_ClearsLivePreviewFingerprint_SetsNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - var property = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - property?.SetValue(client.GetLivePreviewConfig(), "test_fingerprint"); - - // Act - client.ResetLivePreview(); - - // Assert - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Null(value); - } - - [Fact] - public void ResetLivePreview_PreservesEnable_NoChange() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: true); - var originalEnable = client.GetLivePreviewConfig().Enable; - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Equal(originalEnable, client.GetLivePreviewConfig().Enable); - } - - [Fact] - public void ResetLivePreview_PreservesHost_NoChange() - { - // Arrange - var host = "custom.preview.host.com"; - var client = CreateClientWithLivePreview(host: host); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Equal(host, client.GetLivePreviewConfig().Host); - } - - [Fact] - public void ResetLivePreview_PreservesManagementToken_NoChange() - { - // Arrange - var client = CreateClientWithLivePreview(); - var token = "test_mgmt_token"; - client.GetLivePreviewConfig().ManagementToken = token; - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Equal(token, client.GetLivePreviewConfig().ManagementToken); - } - - [Fact] - public void ResetLivePreview_PreservesPreviewToken_NoChange() - { - // Arrange - var client = CreateClientWithLivePreview(); - var token = "test_preview_token"; - client.GetLivePreviewConfig().PreviewToken = token; - - // Act - client.ResetLivePreview(); - - // Assert - Assert.Equal(token, client.GetLivePreviewConfig().PreviewToken); - } - - [Fact] - public void ResetLivePreview_AfterReset_IsCachedPreviewReturnsFalse() - { - // Arrange - var client = CreateClientWithTimeline(); - // Set up a scenario that would normally return true for IsCachedPreviewForCurrentQuery - var config = client.GetLivePreviewConfig(); - config.PreviewResponse = Newtonsoft.Json.Linq.JObject.Parse("{\"test\": \"value\"}"); - - // Set matching fingerprints - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, config.PreviewTimestamp); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, config.ReleaseId); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config)); - - // Verify it would return true before reset - Assert.True(config.IsCachedPreviewForCurrentQuery()); - - // Act - client.ResetLivePreview(); - - // Assert - Assert.False(config.IsCachedPreviewForCurrentQuery()); - } - - [Fact] - public void ResetLivePreview_AfterReset_NoTimelineState() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - client.ResetLivePreview(); - - // Assert - Verify complete timeline state is cleared - TimelineAssertionHelpers.VerifyTimelineStateCleared(client.GetLivePreviewConfig(), "After ResetLivePreview"); - } - - [Fact] - public void ResetLivePreview_AfterReset_NoFingerprintState() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set some fingerprints - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "test_fingerprint"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "test_fingerprint"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "test_fingerprint"); - - // Act - client.ResetLivePreview(); - - // Assert - All fingerprints should be null - Assert.Null(typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config)); - Assert.Null(typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config)); - Assert.Null(typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config)); - } - - [Fact] - public void ResetLivePreview_CalledMultipleTimes_RemainsClean() - { - // Arrange - var client = CreateClientWithTimeline(); - - // Act - Call multiple times - client.ResetLivePreview(); - client.ResetLivePreview(); - client.ResetLivePreview(); - - // Assert - Should remain clean - TimelineAssertionHelpers.VerifyTimelineStateCleared(client.GetLivePreviewConfig(), "After multiple ResetLivePreview calls"); - } - - [Fact] - public void ResetLivePreview_WithAlreadyNullValues_NoChange() - { - // Arrange - var client = CreateClientWithLivePreview(); // No timeline values set - - // Act - client.ResetLivePreview(); - - // Assert - Should handle gracefully - TimelineAssertionHelpers.VerifyTimelineStateCleared(client.GetLivePreviewConfig(), "Reset with already null values"); - } - - [Fact] - public async Task ResetLivePreview_DuringLivePreviewQuery_SafeExecution() - { - // Arrange - var client = CreateClientWithMockHandler(TimelineMockHelpers.CreateMockLivePreviewResponse()); - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - Start LivePreviewQueryAsync and immediately reset (simulate concurrent access) - var queryTask = client.LivePreviewQueryAsync(query); - client.ResetLivePreview(); // This should not cause issues - await queryTask; - - // Assert - Should not throw exceptions - Assert.NotNull(client.GetLivePreviewConfig()); - } - - #endregion - - #region Timeline Preview - LivePreviewQueryAsync() Complete Async Behavior - - [Fact] - public async Task LivePreviewQueryAsync_WithContentTypeUid_Enhanced_SetsContentTypeUID() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(contentTypeUid: "enhanced_ct_uid"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Equal("enhanced_ct_uid", value); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithEntryUid_Enhanced_SetsEntryUID() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(entryUid: "enhanced_entry_uid"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Equal("enhanced_entry_uid", value); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithLivePreview_Enhanced_SetsLivePreview() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(livePreview: "enhanced_hash"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Equal("enhanced_hash", value); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithReleaseId_Enhanced_SetsReleaseId() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(releaseId: "enhanced_release_id"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("enhanced_release_id", client.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithPreviewTimestamp_Enhanced_SetsPreviewTimestamp() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(previewTimestamp: "2024-11-29T14:30:00.000Z"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("2024-11-29T14:30:00.000Z", client.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithAllParameters_SetsAllFields() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery( - contentTypeUid: "all_ct", - entryUid: "all_entry", - livePreview: "all_hash", - releaseId: "all_release", - previewTimestamp: "2024-11-29T14:30:00.000Z" - ); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var config = client.GetLivePreviewConfig(); - Assert.Equal("all_release", config.ReleaseId); - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); - - var ctProperty = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.Equal("all_ct", ctProperty?.GetValue(config)); - - var entryProperty = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.Equal("all_entry", entryProperty?.GetValue(config)); - - var hashProperty = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.Equal("all_hash", hashProperty?.GetValue(config)); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithEmptyStringValues_NormalizesToNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery( - contentTypeUid: "", - entryUid: "", - livePreview: "", - releaseId: "", - previewTimestamp: "" - ); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - SDK normalizes empty strings to null - var config = client.GetLivePreviewConfig(); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_WithNullValues_SetsNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = new Dictionary - { - ["content_type_uid"] = null, - ["entry_uid"] = null, - ["live_preview"] = null, - ["release_id"] = null, - ["preview_timestamp"] = null - }; - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var config = client.GetLivePreviewConfig(); - Assert.Null(config.ReleaseId); - Assert.Null(config.PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_NoContentTypeUid_UsesCurrentContentTypeUid_Enhanced() - { - // Arrange - var client = CreateClientWithLivePreview(); - - // Use a query with all required parameters - var query = new Dictionary - { - ["content_type_uid"] = "test_content_type", - ["entry_uid"] = "test_entry", - ["live_preview"] = "test_hash" - }; - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Configuration should be set from query parameters - var config = client.GetLivePreviewConfig(); - Assert.Equal("test_content_type", config.ContentTypeUID); - Assert.Equal("test_entry", config.EntryUID); - } - - [Fact] - public async Task LivePreviewQueryAsync_NoEntryUid_UsesCurrentEntryUid_Enhanced() - { - // Arrange - var client = CreateClientWithLivePreview(); - - // Use a query with all required parameters - var query = new Dictionary - { - ["content_type_uid"] = "test_content_type", - ["entry_uid"] = "test_entry", - ["live_preview"] = "test_hash" - }; - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Configuration should be set from query parameters - var config = client.GetLivePreviewConfig(); - Assert.Equal("test_content_type", config.ContentTypeUID); - Assert.Equal("test_entry", config.EntryUID); - } - - [Fact] - public async Task LivePreviewQueryAsync_NoCurrentValues_LeavesNull() - { - // Arrange - var client = CreateClientWithLivePreview(); - // Create a truly empty query without default values - var query = new Dictionary(); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var config = client.GetLivePreviewConfig(); - var ctProperty = typeof(LivePreviewConfig).GetProperty("ContentTypeUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var entryProperty = typeof(LivePreviewConfig).GetProperty("EntryUID", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - Assert.Null(ctProperty?.GetValue(config)); - Assert.Null(entryProperty?.GetValue(config)); - } - - [Fact] - public async Task LivePreviewQueryAsync_EmptyQuery_ClearsAllFields() - { - // Arrange - var client = CreateClientWithTimeline(); // Start with timeline values - var query = new Dictionary(); // Empty query - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - All timeline fields should be cleared - TimelineAssertionHelpers.VerifyTimelineStateCleared(client.GetLivePreviewConfig(), "After empty query"); - } - - [Fact] - public async Task LivePreviewQueryAsync_ClearsLivePreview_BeforeProcessing() - { - // Arrange - var client = CreateClientWithTimeline(hash: "old_hash"); - var query = CreateLivePreviewQuery(livePreview: "new_hash"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var property = typeof(LivePreviewConfig).GetProperty("LivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var value = property?.GetValue(client.GetLivePreviewConfig()); - Assert.Equal("new_hash", value); - } - - [Fact] - public async Task LivePreviewQueryAsync_ClearsPreviewTimestamp_BeforeProcessing() - { - // Arrange - var client = CreateClientWithTimeline(timestamp: "old_timestamp"); - var query = CreateLivePreviewQuery(previewTimestamp: "new_timestamp"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("new_timestamp", client.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_ClearsReleaseId_BeforeProcessing() - { - // Arrange - var client = CreateClientWithTimeline(releaseId: "old_release"); - var query = CreateLivePreviewQuery(releaseId: "new_release"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("new_release", client.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public async Task LivePreviewQueryAsync_ClearsPreviewResponse_BeforeProcessing() - { - // Arrange - var client = CreateClientWithLivePreview(); - client.GetLivePreviewConfig().PreviewResponse = Newtonsoft.Json.Linq.JObject.Parse("{\"old\": \"response\"}"); - var query = CreateLivePreviewQuery(); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Should be cleared at start, might be set by prefetch - Assert.True(true); // The clearing happens regardless of prefetch outcome - } - - [Fact] - public async Task LivePreviewQueryAsync_ClearsAllFingerprints_BeforeProcessing() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set old fingerprints - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "old_timestamp_fingerprint"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "old_release_fingerprint"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "old_hash_fingerprint"); - - var query = CreateLivePreviewQuery(); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Fingerprints should be cleared (and potentially reset by prefetch) - Assert.True(true); // The clearing happens regardless of prefetch outcome - } - - [Fact] - public async Task LivePreviewQueryAsync_FromDirtyState_CleansCompletely() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set up dirty state with old values and fingerprints - config.PreviewResponse = Newtonsoft.Json.Linq.JObject.Parse("{\"dirty\": \"state\"}"); - typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.SetValue(config, "dirty_fingerprint"); - - var query = CreateLivePreviewQuery(previewTimestamp: "clean_timestamp"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("clean_timestamp", config.PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_EnabledFalse_SkipsPrefetch() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: false); - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - No network call should be made - Assert.Empty(mockHandler.Requests); - Assert.Null(client.GetLivePreviewConfig().PreviewResponse); - } - - [Fact] - public async Task LivePreviewQueryAsync_HostNull_SkipsPrefetch() - { - // Arrange - Create client with live preview enabled but explicitly set host to null after creation - var client = CreateClientWithLivePreview(enabled: true); - client.GetLivePreviewConfig().Host = null; // Explicitly set host to null - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - No network call should be made when host is null - Assert.Empty(mockHandler.Requests); - } - - [Fact] - public async Task LivePreviewQueryAsync_HostEmpty_SkipsPrefetch() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: true, host: ""); - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - No network call should be made - Assert.Empty(mockHandler.Requests); - } - - [Fact] - public async Task LivePreviewQueryAsync_AllConditionsMet_AttemptsPrefetch() - { - // Arrange - var client = CreateClientWithLivePreview(enabled: true); - var mockHandler = new TimelineMockHttpHandler().ForSuccessfulLivePreview("test_entry", "test_ct"); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Network call should be made - Assert.NotEmpty(mockHandler.Requests); - } - - [Fact] - public async Task LivePreviewQueryAsync_SuccessfulPrefetch_AttemptsNetworkCall() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockResponse = TimelineMockHelpers.CreateMockLivePreviewResponse("test_entry", "test_ct"); - var mockHandler = new TimelineMockHttpHandler().ForLivePreview(JObject.Parse(mockResponse)); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Verify that prefetch was attempted (network call made) - Assert.NotEmpty(mockHandler.Requests); - - // Verify that basic config is set regardless of prefetch success/failure - var config = client.GetLivePreviewConfig(); - Assert.Equal("test_ct", config.ContentTypeUID); - Assert.Equal("test_entry", config.EntryUID); - } - - [Fact] - public async Task LivePreviewQueryAsync_SuccessfulPrefetch_SetsAllFingerprints() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockResponse = TimelineMockHelpers.CreateMockLivePreviewResponse("test_entry", "test_ct"); - var mockHandler = new TimelineMockHttpHandler().ForLivePreview(JObject.Parse(mockResponse)); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery( - contentTypeUid: "test_ct", - entryUid: "test_entry", - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "test_release", - livePreview: "test_hash" - ); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - var config = client.GetLivePreviewConfig(); - if (config.PreviewResponse != null) // Only check if prefetch was successful - { - var timestampFingerprint = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintPreviewTimestamp", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config); - var releaseFingerprint = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintReleaseId", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config); - var hashFingerprint = typeof(LivePreviewConfig).GetProperty("PreviewResponseFingerprintLivePreview", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(config); - - Assert.Equal("2024-11-29T14:30:00.000Z", timestampFingerprint); - Assert.Equal("test_release", releaseFingerprint); - Assert.Equal("test_hash", hashFingerprint); - } - } - - [Fact] - public async Task LivePreviewQueryAsync_PrefetchThrowsException_SwallowsException() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler().ThrowTimeout(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act & Assert - Should not throw - await client.LivePreviewQueryAsync(query); - - // Should complete without exceptions - Assert.NotNull(client.GetLivePreviewConfig()); - } - - [Fact] - public async Task LivePreviewQueryAsync_NetworkError_SwallowsException() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler().ThrowWebException("Network error"); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery(contentTypeUid: "test_ct", entryUid: "test_entry"); - - // Act & Assert - Should not throw - await client.LivePreviewQueryAsync(query); - - // Should complete without exceptions - Assert.NotNull(client.GetLivePreviewConfig()); - } - - [Fact] - public async Task LivePreviewQueryAsync_ExecutesAsynchronously_ReturnsTask() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(); - - // Act - var task = client.LivePreviewQueryAsync(query); - - // Assert - Assert.IsAssignableFrom(task); - await task; // Should complete - } - - [Fact] - public async Task LivePreviewQueryAsync_CanAwait_CompletesSuccessfully() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query = CreateLivePreviewQuery(previewTimestamp: "2024-11-29T14:30:00.000Z"); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Assert.Equal("2024-11-29T14:30:00.000Z", client.GetLivePreviewConfig().PreviewTimestamp); - } - - [Fact] - public async Task LivePreviewQueryAsync_MultipleSimultaneous_HandledCorrectly() - { - // Arrange - var client = CreateClientWithLivePreview(); - var query1 = CreateLivePreviewQuery(previewTimestamp: "timestamp1"); - var query2 = CreateLivePreviewQuery(previewTimestamp: "timestamp2"); - - // Act - Run simultaneously - var task1 = client.LivePreviewQueryAsync(query1); - var task2 = client.LivePreviewQueryAsync(query2); - await Task.WhenAll(task1, task2); - - // Assert - Should handle concurrent access gracefully (final state may be from either) - Assert.True(client.GetLivePreviewConfig().PreviewTimestamp == "timestamp1" || - client.GetLivePreviewConfig().PreviewTimestamp == "timestamp2"); - } - - #endregion - [Fact] public void GetHeader_WithLocalHeaderAndEmptyStackHeaders_ReturnsLocalHeader() { diff --git a/Contentstack.Core.Unit.Tests/Helpers/ContentstackClientTestBase.cs b/Contentstack.Core.Unit.Tests/Helpers/ContentstackClientTestBase.cs deleted file mode 100644 index 6ec0d172..00000000 --- a/Contentstack.Core.Unit.Tests/Helpers/ContentstackClientTestBase.cs +++ /dev/null @@ -1,424 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Unit.Tests.Mokes; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace Contentstack.Core.Unit.Tests.Helpers -{ - /// - /// Base class for ContentstackClient unit tests providing common setup and helper methods - /// - public abstract class ContentstackClientTestBase - { - protected readonly IFixture _fixture = new Fixture(); - - /// - /// Creates a basic ContentstackClient with default configuration for testing - /// - /// ContentstackClient instance with test configuration - protected ContentstackClient CreateClient() - { - var options = new ContentstackOptions - { - ApiKey = _fixture.Create(), - DeliveryToken = _fixture.Create(), - Environment = _fixture.Create(), - Host = "cdn.contentstack.io" - }; - - return new ContentstackClient(options); - } - - /// - /// Creates a ContentstackClient with specified environment - /// - /// Environment name - /// ContentstackClient with specified environment - protected ContentstackClient CreateClient(string environment) - { - var options = new ContentstackOptions - { - ApiKey = _fixture.Create(), - DeliveryToken = _fixture.Create(), - Environment = environment, - Host = "cdn.contentstack.io" - }; - - return new ContentstackClient(options); - } - - /// - /// Creates a ContentstackClient with named parameters (for backward compatibility) - /// - protected ContentstackClient CreateClient(string apiKey = null, string deliveryToken = null, string environment = null, string version = null) - { - var options = new ContentstackOptions - { - ApiKey = apiKey ?? _fixture.Create(), - DeliveryToken = deliveryToken ?? _fixture.Create(), - Environment = environment ?? _fixture.Create(), - Host = "cdn.contentstack.io" - }; - - return new ContentstackClient(options); - } - - /// - /// Creates a ContentstackClient with LivePreview configuration - /// - /// Whether LivePreview should be enabled - /// Optional management token - /// Optional preview token - /// Optional host override - /// ContentstackClient with LivePreview configuration - protected ContentstackClient CreateClientWithLivePreview(bool enabled = true, string managementToken = null, string previewToken = null, string host = null) - { - var options = new ContentstackOptions - { - ApiKey = _fixture.Create(), - DeliveryToken = _fixture.Create(), - Environment = _fixture.Create(), - Host = "cdn.contentstack.io", - LivePreview = new LivePreviewConfig - { - Enable = enabled, - Host = host ?? (enabled ? "rest-preview.contentstack.com" : null), - ManagementToken = managementToken ?? (enabled ? _fixture.Create() : null), - PreviewToken = previewToken - } - }; - - return new ContentstackClient(options); - } - - /// - /// Creates a ContentstackClient configured for timeline operations - /// - /// ContentstackClient with timeline-ready configuration - protected ContentstackClient CreateClientWithTimeline() - { - var client = CreateClientWithLivePreview(enabled: true); - var config = client.GetLivePreviewConfig(); - - // Set up basic timeline context - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "test_release_123"; - - return client; - } - - /// - /// Creates a ContentstackClient configured for timeline operations (named parameters for backward compatibility) - /// - protected ContentstackClient CreateClientWithTimeline(string releaseId = null, string timestamp = null, string hash = null) - { - var client = CreateClientWithLivePreview(enabled: true); - var config = client.GetLivePreviewConfig(); - - // Set up timeline context - config.PreviewTimestamp = timestamp ?? "2024-11-29T14:30:00.000Z"; - config.ReleaseId = releaseId ?? "test_release_123"; - - if (!string.IsNullOrEmpty(hash)) - { - SetInternalProperty(config, "LivePreview", hash); - } - - return client; - } - - /// - /// Creates a live preview query dictionary with specified parameters - /// - /// Content type UID - /// Entry UID - /// Preview timestamp - /// Release ID - /// Live preview hash - /// Dictionary with live preview query parameters - protected Dictionary CreateLivePreviewQuery( - string contentTypeUid = "test_ct", - string entryUid = "test_entry", - string previewTimestamp = null, - string releaseId = null, - string livePreview = "init") - { - var query = new Dictionary - { - ["content_type_uid"] = contentTypeUid, - ["entry_uid"] = entryUid, - ["live_preview"] = livePreview - }; - - if (!string.IsNullOrEmpty(previewTimestamp)) - query["preview_timestamp"] = previewTimestamp; - - if (!string.IsNullOrEmpty(releaseId)) - query["release_id"] = releaseId; - - return query; - } - - /// - /// Sets internal property value using reflection (for testing internal state) - /// - /// Target object - /// Property name - /// Value to set - protected void SetInternalProperty(object target, string propertyName, object value) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - property?.SetValue(target, value); - } - - /// - /// Gets internal property value using reflection (for testing internal state) - /// - /// Expected return type - /// Target object - /// Property name - /// Property value - protected T GetInternalProperty(object target, string propertyName) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - return (T)property?.GetValue(target); - } - - /// - /// Sets internal field value using reflection (for testing internal state) - /// - /// Target object - /// Field name - /// Value to set - protected void SetInternalField(object target, string fieldName, object value) - { - var field = target.GetType().GetField(fieldName, - BindingFlags.NonPublic | BindingFlags.Instance); - field?.SetValue(target, value); - } - - /// - /// Gets internal field value using reflection (for testing internal state) - /// - /// Expected return type - /// Target object - /// Field name - /// Field value - protected T GetInternalField(object target, string fieldName) - { - var field = target.GetType().GetField(fieldName, - BindingFlags.NonPublic | BindingFlags.Instance); - return (T)field?.GetValue(target); - } - - /// - /// Creates a mock JObject response for timeline preview testing - /// - /// Entry UID for the mock response - /// Content Type UID for the mock response - /// Mock JObject response - protected JObject CreateMockPreviewResponse(string entryUid = "mock_entry", string contentTypeUid = "mock_ct") - { - return JObject.Parse($@"{{ - ""uid"": ""{entryUid}"", - ""content_type_uid"": ""{contentTypeUid}"", - ""title"": ""Mock Entry Title"", - ""created_at"": ""2024-11-29T14:30:00.000Z"", - ""updated_at"": ""2024-11-29T15:45:00.000Z"", - ""publish_details"": {{ - ""environment"": ""test"", - ""locale"": ""en-us"", - ""time"": ""2024-11-29T16:00:00.000Z"" - }}, - ""mock_field"": ""mock_value_{_fixture.Create()}"" - }}"); - } - - /// - /// Verifies that two clients are independent instances - /// - /// First client - /// Second client - protected void AssertClientsAreIndependent(ContentstackClient client1, ContentstackClient client2) - { - if (client1 == null) throw new ArgumentNullException(nameof(client1)); - if (client2 == null) throw new ArgumentNullException(nameof(client2)); - - // Verify they are different instances - Assert.NotSame(client1, client2); - - // Verify LivePreview configs are different instances - Assert.NotSame(client1.GetLivePreviewConfig(), client2.GetLivePreviewConfig()); - } - - /// - /// Verifies that client configuration is properly preserved - /// - /// Original client - /// New client to verify - protected void AssertConfigurationPreserved(ContentstackClient originalClient, ContentstackClient newClient) - { - Assert.Equal(originalClient.GetApplicationKey(), newClient.GetApplicationKey()); - Assert.Equal(originalClient.GetAccessToken(), newClient.GetAccessToken()); - Assert.Equal(originalClient.GetEnvironment(), newClient.GetEnvironment()); - Assert.Equal(originalClient.GetVersion(), newClient.GetVersion()); - } - - /// - /// Helper method to create a client with mock handler (for backward compatibility) - /// - protected ContentstackClient CreateClientWithMockHandler(object mockHandler) - { - // Simple implementation - just returns a basic client - return CreateClient(); - } - - #region Helper Classes for Backward Compatibility - - /// - /// Mock helpers for timeline testing (stub implementation) - /// - protected static class TimelineMockHelpers - { - public static object CreateSuccessfulMockHandler() - { - return new { Success = true }; - } - - public static object CreateFailureMockHandler() - { - return new { Success = false }; - } - - public static string CreateMockLivePreviewResponse(string entryUid = "test_entry", string contentTypeUid = "test_ct") - { - return $@"{{ - ""entry"": {{ - ""uid"": ""{entryUid}"", - ""content_type_uid"": ""{contentTypeUid}"", - ""title"": ""Mock Timeline Entry"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""2024-11-29T14:30:00.000Z"", - ""publish_details"": {{ - ""environment"": ""test"", - ""locale"": ""en-us"", - ""time"": ""2024-11-29T14:30:00.000Z"" - }}, - ""mock_field"": ""timeline_test_value"" - }} - }}"; - } - } - - /// - /// Assertion helpers for timeline testing (stub implementation) - /// - protected static class TimelineAssertionHelpers - { - public static void AssertTimelineStateCleared(ContentstackClient client) - { - var config = client.GetLivePreviewConfig(); - Assert.Null(config?.PreviewTimestamp); - Assert.Null(config?.ReleaseId); - } - - public static void VerifyTimelineStateCleared(LivePreviewConfig config, string message = "") - { - Assert.Null(config?.PreviewTimestamp); - Assert.Null(config?.ReleaseId); - Assert.Null(config?.PreviewResponse); - } - - public static void AssertTimelineStatePreserved(ContentstackClient client, string expectedTimestamp, string expectedReleaseId) - { - var config = client.GetLivePreviewConfig(); - Assert.Equal(expectedTimestamp, config?.PreviewTimestamp); - Assert.Equal(expectedReleaseId, config?.ReleaseId); - } - - public static void AssertCacheState(ContentstackClient client, bool expectedCached) - { - var config = client.GetLivePreviewConfig(); - if (expectedCached) - { - Assert.NotNull(config?.PreviewResponse); - } - else - { - Assert.Null(config?.PreviewResponse); - } - } - } - - #endregion - - #region ContentstackClient Extension Methods for Testing - - /// - /// Mock implementation of SetHost method for testing - /// - protected void SetHost(ContentstackClient client, string host) - { - // Mock implementation - store in a private field or ignore for testing - SetInternalProperty(client, "_mockHost", host); - } - - /// - /// Mock implementation of GetHost method for testing - /// - protected string GetHost(ContentstackClient client) - { - return GetInternalProperty(client, "_mockHost") ?? "cdn.contentstack.io"; - } - - /// - /// Mock implementation of SetTimeout method for testing - /// - protected void SetTimeout(ContentstackClient client, int timeout) - { - SetInternalProperty(client, "_mockTimeout", timeout); - } - - /// - /// Mock implementation of GetTimeout method for testing - /// - protected int GetTimeout(ContentstackClient client) - { - // Provide default timeout value since there's no actual _mockTimeout property - return 30000; // 30 seconds default - } - - /// - /// Mock implementation of GetRegion method for testing - /// - protected string GetRegion(ContentstackClient client) - { - return GetInternalProperty(client, "_mockRegion") ?? "us"; - } - - /// - /// Mock implementation of SetBranch method for testing - /// - protected void SetBranch(ContentstackClient client, string branch) - { - SetInternalProperty(client, "_mockBranch", branch); - } - - /// - /// Mock implementation of GetBranch method for testing - /// - protected string GetBranch(ContentstackClient client) - { - return GetInternalProperty(client, "_mockBranch") ?? "main"; - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/Helpers/TimelinePerformanceHelpers.cs b/Contentstack.Core.Unit.Tests/Helpers/TimelinePerformanceHelpers.cs deleted file mode 100644 index 1d1a2e79..00000000 --- a/Contentstack.Core.Unit.Tests/Helpers/TimelinePerformanceHelpers.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Xunit; - -namespace Contentstack.Core.Unit.Tests.Helpers -{ - /// - /// Helper methods for Timeline Preview performance testing - /// - public static class TimelinePerformanceHelpers - { - /// - /// Measures execution time of a synchronous operation - /// - /// Operation to measure - /// Elapsed time - public static TimeSpan MeasureExecutionTime(Action operation) - { - var stopwatch = Stopwatch.StartNew(); - operation(); - stopwatch.Stop(); - return stopwatch.Elapsed; - } - - /// - /// Measures execution time of an asynchronous operation - /// - /// Async operation to measure - /// Elapsed time - public static async Task MeasureExecutionTime(Func operation) - { - var stopwatch = Stopwatch.StartNew(); - await operation(); - stopwatch.Stop(); - return stopwatch.Elapsed; - } - - /// - /// Asserts that cache operation is significantly faster than network operation - /// - /// Cache operation to test - /// Network operation to compare against - /// Test description for assertion messages - /// Minimum expected speedup factor (default: 5x) - public static async Task AssertCachePerformance( - Func cacheOperation, - Func networkOperation, - string description, - int minimumSpeedupFactor = 5) - { - // Measure cache operation - var cacheTime = await MeasureExecutionTime(cacheOperation); - - // Measure network operation - var networkTime = await MeasureExecutionTime(networkOperation); - - // Calculate speedup factor - var speedupFactor = networkTime.TotalMilliseconds / Math.Max(cacheTime.TotalMilliseconds, 0.001); - - Assert.True(speedupFactor >= minimumSpeedupFactor, - $"{description}: Cache operation ({cacheTime.TotalMilliseconds:F2}ms) should be at least {minimumSpeedupFactor}x faster than network operation ({networkTime.TotalMilliseconds:F2}ms). Actual speedup: {speedupFactor:F2}x"); - } - - /// - /// Asserts that an operation doesn't cause significant memory leaks - /// - /// Operation to test for memory leaks - /// Number of iterations to run - /// Maximum allowed memory growth in bytes - public static void AssertNoMemoryLeak(Action operation, int iterations, long maxMemoryGrowth) - { - // Force initial garbage collection - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - var initialMemory = GC.GetTotalMemory(true); - - // Run the operation multiple times - for (int i = 0; i < iterations; i++) - { - operation(); - } - - // Force garbage collection again - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - var finalMemory = GC.GetTotalMemory(true); - var memoryGrowth = finalMemory - initialMemory; - - Assert.True(memoryGrowth <= maxMemoryGrowth, - $"Memory grew by {memoryGrowth:N0} bytes over {iterations} iterations. Maximum allowed: {maxMemoryGrowth:N0} bytes"); - } - - /// - /// Measures average execution time over multiple iterations - /// - /// Operation to measure - /// Number of iterations - /// Average execution time per iteration - public static TimeSpan MeasureAverageExecutionTime(Action operation, int iterations) - { - var totalTime = MeasureExecutionTime(() => - { - for (int i = 0; i < iterations; i++) - { - operation(); - } - }); - - return TimeSpan.FromTicks(totalTime.Ticks / iterations); - } - - /// - /// Measures average execution time of async operations over multiple iterations - /// - /// Async operation to measure - /// Number of iterations - /// Average execution time per iteration - public static async Task MeasureAverageExecutionTime(Func operation, int iterations) - { - var totalTime = await MeasureExecutionTime(async () => - { - for (int i = 0; i < iterations; i++) - { - await operation(); - } - }); - - return TimeSpan.FromTicks(totalTime.Ticks / iterations); - } - - /// - /// Asserts that an operation completes within the specified time limit - /// - /// Operation to test - /// Maximum allowed execution time - /// Description for assertion message - public static void AssertExecutionTime(Action operation, TimeSpan timeLimit, string description) - { - var executionTime = MeasureExecutionTime(operation); - - Assert.True(executionTime <= timeLimit, - $"{description}: Operation took {executionTime.TotalMilliseconds:F2}ms, should be under {timeLimit.TotalMilliseconds:F2}ms"); - } - - /// - /// Asserts that an async operation completes within the specified time limit - /// - /// Async operation to test - /// Maximum allowed execution time - /// Description for assertion message - public static async Task AssertExecutionTime(Func operation, TimeSpan timeLimit, string description) - { - var executionTime = await MeasureExecutionTime(operation); - - Assert.True(executionTime <= timeLimit, - $"{description}: Operation took {executionTime.TotalMilliseconds:F2}ms, should be under {timeLimit.TotalMilliseconds:F2}ms"); - } - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/Helpers/TimelineTestDataBuilder.cs b/Contentstack.Core.Unit.Tests/Helpers/TimelineTestDataBuilder.cs deleted file mode 100644 index 48c9cbe4..00000000 --- a/Contentstack.Core.Unit.Tests/Helpers/TimelineTestDataBuilder.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using AutoFixture; -using Contentstack.Core.Configuration; -using Newtonsoft.Json.Linq; - -namespace Contentstack.Core.Unit.Tests.Helpers -{ - /// - /// Fluent builder for creating Timeline Preview test data and configurations - /// - public class TimelineTestDataBuilder - { - private readonly IFixture _fixture = new Fixture(); - private LivePreviewConfig _config; - - private TimelineTestDataBuilder() - { - _config = new LivePreviewConfig(); - } - - /// - /// Creates a new TimelineTestDataBuilder instance - /// - /// New builder instance - public static TimelineTestDataBuilder New() - { - return new TimelineTestDataBuilder(); - } - - /// - /// Sets the preview timestamp for timeline operations - /// - /// ISO 8601 timestamp string - /// Builder instance for chaining - public TimelineTestDataBuilder WithPreviewTimestamp(string timestamp) - { - _config.PreviewTimestamp = timestamp; - return this; - } - - /// - /// Sets the preview timestamp using a DateTime - /// - /// DateTime to convert to ISO 8601 string - /// Builder instance for chaining - public TimelineTestDataBuilder WithPreviewTimestamp(DateTime dateTime) - { - _config.PreviewTimestamp = dateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - return this; - } - - /// - /// Sets the release ID for timeline operations - /// - /// Release identifier - /// Builder instance for chaining - public TimelineTestDataBuilder WithReleaseId(string releaseId) - { - _config.ReleaseId = releaseId; - return this; - } - - /// - /// Sets the live preview hash - /// - /// Live preview hash - /// Builder instance for chaining - public TimelineTestDataBuilder WithLivePreview(string hash) - { - SetInternalProperty(_config, "LivePreview", hash); - return this; - } - - /// - /// Sets the content type UID for the configuration - /// - /// Content type identifier - /// Builder instance for chaining - public TimelineTestDataBuilder WithContentTypeUid(string contentTypeUid) - { - SetInternalProperty(_config, "ContentTypeUID", contentTypeUid); - return this; - } - - /// - /// Sets the entry UID for the configuration - /// - /// Entry identifier - /// Builder instance for chaining - public TimelineTestDataBuilder WithEntryUid(string entryUid) - { - SetInternalProperty(_config, "EntryUID", entryUid); - return this; - } - - /// - /// Sets the management token - /// - /// Management token - /// Builder instance for chaining - public TimelineTestDataBuilder WithManagementToken(string token) - { - _config.ManagementToken = token; - return this; - } - - /// - /// Sets the preview token - /// - /// Preview token - /// Builder instance for chaining - public TimelineTestDataBuilder WithPreviewToken(string token) - { - _config.PreviewToken = token; - return this; - } - - /// - /// Enables or disables live preview - /// - /// Whether live preview should be enabled - /// Builder instance for chaining - public TimelineTestDataBuilder WithEnabled(bool enabled) - { - _config.Enable = enabled; - return this; - } - - /// - /// Sets the preview host - /// - /// Preview host URL - /// Builder instance for chaining - public TimelineTestDataBuilder WithHost(string host) - { - _config.Host = host; - return this; - } - - /// - /// Sets a mock preview response - /// - /// JObject response - /// Builder instance for chaining - public TimelineTestDataBuilder WithPreviewResponse(JObject response = null) - { - _config.PreviewResponse = response ?? CreateDefaultPreviewResponse(); - return this; - } - - /// - /// Sets matching fingerprints to create a cache hit scenario - /// - /// Builder instance for chaining - public TimelineTestDataBuilder WithMatchingFingerprints() - { - SetInternalProperty(_config, "PreviewResponseFingerprintPreviewTimestamp", _config.PreviewTimestamp); - SetInternalProperty(_config, "PreviewResponseFingerprintReleaseId", _config.ReleaseId); - SetInternalProperty(_config, "PreviewResponseFingerprintLivePreview", GetInternalProperty(_config, "LivePreview")); - return this; - } - - /// - /// Sets non-matching fingerprints to create a cache miss scenario - /// - /// Builder instance for chaining - public TimelineTestDataBuilder WithNonMatchingFingerprints() - { - SetInternalProperty(_config, "PreviewResponseFingerprintPreviewTimestamp", $"different_{_fixture.Create()}"); - SetInternalProperty(_config, "PreviewResponseFingerprintReleaseId", $"different_{_fixture.Create()}"); - SetInternalProperty(_config, "PreviewResponseFingerprintLivePreview", $"different_{_fixture.Create()}"); - return this; - } - - /// - /// Clears all fingerprints (simulates no previous cache) - /// - /// Builder instance for chaining - public TimelineTestDataBuilder WithNoFingerprints() - { - SetInternalProperty(_config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(_config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(_config, "PreviewResponseFingerprintLivePreview", null); - return this; - } - - /// - /// Creates a default timeline configuration - /// - /// Builder instance for chaining - public TimelineTestDataBuilder WithDefaultTimelineConfig() - { - return WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("default_release_123") - .WithLivePreview("default_hash_456") - .WithContentTypeUid("default_ct") - .WithEntryUid("default_entry") - .WithEnabled(true) - .WithHost("rest-preview.contentstack.com") - .WithManagementToken(_fixture.Create()); - } - - /// - /// Builds and returns the configured LivePreviewConfig - /// - /// Configured LivePreviewConfig instance - public LivePreviewConfig Build() - { - return _config; - } - - /// - /// Creates a valid preview response JObject for testing - /// - /// Entry UID for the response - /// Content Type UID for the response - /// Mock JObject preview response - public JObject CreateValidPreviewResponse(string entryUid = null, string contentTypeUid = null) - { - entryUid ??= _fixture.Create(); - contentTypeUid ??= _fixture.Create(); - - return JObject.Parse($@"{{ - ""uid"": ""{entryUid}"", - ""content_type_uid"": ""{contentTypeUid}"", - ""title"": ""Timeline Preview Test Entry"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""2024-11-29T14:30:00.000Z"", - ""publish_details"": {{ - ""environment"": ""test"", - ""locale"": ""en-us"", - ""time"": ""{_config.PreviewTimestamp ?? "2024-11-29T14:30:00.000Z"}"" - }}, - ""test_field"": ""timeline_test_value_{_fixture.Create()}"", - ""metadata"": {{ - ""timeline_marker"": ""{DateTime.UtcNow.Ticks}"" - }} - }}"); - } - - /// - /// Creates a preview response for a specific timeline scenario - /// - /// Scenario identifier - /// Scenario-specific JObject response - public JObject CreateScenarioResponse(string scenario) - { - var baseResponse = CreateValidPreviewResponse(); - baseResponse["scenario"] = scenario; - baseResponse["timestamp"] = _config.PreviewTimestamp; - baseResponse["release_id"] = _config.ReleaseId; - return baseResponse; - } - - /// - /// Creates a live preview query dictionary from the current configuration - /// - /// Dictionary suitable for LivePreviewQueryAsync - public Dictionary CreateQuery() - { - var query = new Dictionary(); - - var contentTypeUid = GetInternalProperty(_config, "ContentTypeUID"); - var entryUid = GetInternalProperty(_config, "EntryUID"); - var livePreview = GetInternalProperty(_config, "LivePreview"); - - if (!string.IsNullOrEmpty(contentTypeUid)) - query["content_type_uid"] = contentTypeUid; - - if (!string.IsNullOrEmpty(entryUid)) - query["entry_uid"] = entryUid; - - if (!string.IsNullOrEmpty(livePreview)) - query["live_preview"] = livePreview; - - if (!string.IsNullOrEmpty(_config.PreviewTimestamp)) - query["preview_timestamp"] = _config.PreviewTimestamp; - - if (!string.IsNullOrEmpty(_config.ReleaseId)) - query["release_id"] = _config.ReleaseId; - - return query; - } - - #region Private Helper Methods - - private JObject CreateDefaultPreviewResponse() - { - return CreateValidPreviewResponse(); - } - - private void SetInternalProperty(object target, string propertyName, object value) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - property?.SetValue(target, value); - } - - private T GetInternalProperty(object target, string propertyName) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - return (T)property?.GetValue(target); - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/LivePreviewConfigFingerprintTests.cs b/Contentstack.Core.Unit.Tests/LivePreviewConfigFingerprintTests.cs deleted file mode 100644 index 9f7c3363..00000000 --- a/Contentstack.Core.Unit.Tests/LivePreviewConfigFingerprintTests.cs +++ /dev/null @@ -1,531 +0,0 @@ -using System; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Unit.Tests.Helpers; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Unit tests for LivePreviewConfig.IsCachedPreviewForCurrentQuery() method - /// Tests fingerprint-based cache hit/miss logic for Timeline Preview - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Fingerprint")] - [Trait("Category", "Cache")] - public class LivePreviewConfigFingerprintTests : ContentstackClientTestBase - { - #region Cache Hit Scenarios (All Fingerprints Match) - - [Fact] - public void IsCachedPreviewForCurrentQuery_AllFingerprintsMatch_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithLivePreview("test_hash_456") - .WithPreviewResponse() - .WithMatchingFingerprints() - .Build(); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_OnlyTimestampSet_MatchingFingerprint_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .Build(); - - // Set matching fingerprint only for timestamp - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_OnlyReleaseIdSet_MatchingFingerprint_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithReleaseId("test_release_123") - .WithPreviewResponse() - .Build(); - - // Set matching fingerprint only for release ID - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_OnlyLivePreviewSet_MatchingFingerprint_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithLivePreview("test_hash_789") - .WithPreviewResponse() - .Build(); - - // Set matching fingerprint only for live preview - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config, "LivePreview")); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_AllNullValues_MatchingNullFingerprints_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewResponse() - .Build(); - - // Ensure all values and fingerprints are null - config.PreviewTimestamp = null; - config.ReleaseId = null; - SetInternalProperty(config, "LivePreview", null); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_EmptyStrings_MatchingEmptyFingerprints_ReturnsTrue() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewResponse() - .Build(); - - // Set empty strings for current values and matching fingerprints - config.PreviewTimestamp = ""; - config.ReleaseId = ""; - SetInternalProperty(config, "LivePreview", ""); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", ""); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", ""); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", ""); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - #endregion - - #region Cache Miss Scenarios - - [Fact] - public void IsCachedPreviewForCurrentQuery_NullPreviewResponse_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithMatchingFingerprints() - .Build(); - - config.PreviewResponse = null; - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_DifferentTimestamp_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithPreviewResponse() - .Build(); - - // Set different fingerprint for timestamp - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T10:00:00.000Z"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config, "LivePreview")); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_DifferentReleaseId_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithPreviewResponse() - .Build(); - - // Set different fingerprint for release ID - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "different_release_456"); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config, "LivePreview")); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_DifferentLivePreviewHash_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithLivePreview("current_hash") - .WithPreviewResponse() - .Build(); - - // Set different fingerprint for live preview hash - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "different_hash"); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_NullCurrentTimestamp_NonNullFingerprint_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithReleaseId("test_release_123") - .WithPreviewResponse() - .Build(); - - config.PreviewTimestamp = null; - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T14:30:00.000Z"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_NonNullCurrentTimestamp_NullFingerprint_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithPreviewResponse() - .Build(); - - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_NullCurrentReleaseId_NonNullFingerprint_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .Build(); - - config.ReleaseId = null; - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "fingerprint_release"); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_PartialMatch_TimestampAndReleaseMatch_LivePreviewDifferent_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release_123") - .WithLivePreview("current_hash") - .WithPreviewResponse() - .Build(); - - // Set matching fingerprints for timestamp and release, but different for live preview - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "different_hash"); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); - } - - #endregion - - #region Edge Cases - - [Fact] - public void IsCachedPreviewForCurrentQuery_EmptyStringVsNull_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewResponse() - .Build(); - - // Current values are empty strings, fingerprints are null - config.PreviewTimestamp = ""; - config.ReleaseId = ""; - SetInternalProperty(config, "LivePreview", ""); - - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); // Empty string != null - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_NullVsEmptyString_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewResponse() - .Build(); - - // Current values are null, fingerprints are empty strings - config.PreviewTimestamp = null; - config.ReleaseId = null; - SetInternalProperty(config, "LivePreview", null); - - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", ""); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", ""); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", ""); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); // null != empty string - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_CaseSensitive_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("Test_Release_123") - .WithLivePreview("Test_Hash") - .WithPreviewResponse() - .Build(); - - // Set fingerprints with different casing - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "test_release_123"); // Different case - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "test_hash"); // Different case - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); // Should be case-sensitive - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_WhitespaceSignificant_ReturnsFalse() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .Build(); - - // Set fingerprint with extra whitespace - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", " 2024-11-29T14:30:00.000Z "); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(isCached); // Whitespace should be significant - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_UnicodeCharacters_MatchesCorrectly() - { - // Arrange - var unicodeTimestamp = "2024-11-29T14:30:00.000Z-üñíçødé"; - var unicodeReleaseId = "release-测试-🚀"; - var unicodeHash = "hash-αβγδε-💯"; - - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp(unicodeTimestamp) - .WithReleaseId(unicodeReleaseId) - .WithLivePreview(unicodeHash) - .WithPreviewResponse() - .Build(); - - // Set matching unicode fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", unicodeTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", unicodeReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", unicodeHash); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_VeryLongStrings_HandlesCorrectly() - { - // Arrange - var longTimestamp = "2024-11-29T14:30:00.000Z" + new string('x', 1000); - var longReleaseId = "release_" + new string('y', 1000); - var longHash = "hash_" + new string('z', 1000); - - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp(longTimestamp) - .WithReleaseId(longReleaseId) - .WithLivePreview(longHash) - .WithPreviewResponse() - .Build(); - - // Set matching long fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", longTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", longReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", longHash); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_SpecialCharacters_HandlesCorrectly() - { - // Arrange - var specialTimestamp = "2024-11-29T14:30:00.000Z!@#$%^&*()"; - var specialReleaseId = "release_<>&\"'`~"; - var specialHash = "hash_{[]}|\\:;?,./"; - - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp(specialTimestamp) - .WithReleaseId(specialReleaseId) - .WithLivePreview(specialHash) - .WithPreviewResponse() - .Build(); - - // Set matching special character fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", specialTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", specialReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", specialHash); - - // Act - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(isCached); - } - - #endregion - - #region Performance - - [Fact] - public void IsCachedPreviewForCurrentQuery_Performance_FastComparison() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("performance_test_release") - .WithLivePreview("performance_test_hash") - .WithPreviewResponse() - .WithMatchingFingerprints() - .Build(); - - var iterations = 10000; - var startTime = DateTime.UtcNow; - - // Act - Multiple cache checks - for (int i = 0; i < iterations; i++) - { - var result = config.IsCachedPreviewForCurrentQuery(); - Assert.True(result); // Verify correctness during performance test - } - - var duration = DateTime.UtcNow - startTime; - - // Assert - Should be very fast (under 50ms for 10,000 checks) - Assert.True(duration.TotalMilliseconds < 50, - $"Cache check took {duration.TotalMilliseconds}ms for {iterations} operations"); - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs b/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs index 8a688f98..cef349fb 100644 --- a/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using AutoFixture; using Contentstack.Core.Configuration; using Xunit; @@ -152,309 +151,6 @@ public void LivePreviewConfig_WithAllPropertiesSet_ReturnsAllValues() } #endregion - - #region Timeline Preview Property Tests - - [Fact] - public void PreviewResponse_SetAndGet_ReturnsCorrectValue() - { - // Arrange - var config = new LivePreviewConfig(); - var response = JObject.Parse(@"{ - ""entry"": { - ""uid"": ""test_entry"", - ""title"": ""Test Entry"" - } - }"); - - // Act - config.PreviewResponse = response; - - // Assert - Assert.Same(response, config.PreviewResponse); - } - - [Fact] - public void PreviewResponse_SetNull_ReturnsNull() - { - // Arrange - var config = new LivePreviewConfig(); - config.PreviewResponse = JObject.Parse(@"{ ""test"": ""value"" }"); - - // Act - config.PreviewResponse = null; - - // Assert - Assert.Null(config.PreviewResponse); - } - - #endregion - - #region Fingerprint Property Tests - - [Fact] - public void PreviewResponseFingerprintPreviewTimestamp_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var timestamp = "2024-11-29T14:30:00.000Z"; - - // Act - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", timestamp); - var result = GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp"); - - // Assert - Assert.Equal(timestamp, result); - } - - [Fact] - public void PreviewResponseFingerprintReleaseId_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var releaseId = _fixture.Create(); - - // Act - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", releaseId); - var result = GetInternalProperty(config, "PreviewResponseFingerprintReleaseId"); - - // Assert - Assert.Equal(releaseId, result); - } - - [Fact] - public void PreviewResponseFingerprintLivePreview_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var hash = _fixture.Create(); - - // Act - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", hash); - var result = GetInternalProperty(config, "PreviewResponseFingerprintLivePreview"); - - // Assert - Assert.Equal(hash, result); - } - - [Fact] - public void ContentTypeUID_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var contentTypeUid = _fixture.Create(); - - // Act - SetInternalProperty(config, "ContentTypeUID", contentTypeUid); - var result = GetInternalProperty(config, "ContentTypeUID"); - - // Assert - Assert.Equal(contentTypeUid, result); - } - - [Fact] - public void EntryUID_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var entryUid = _fixture.Create(); - - // Act - SetInternalProperty(config, "EntryUID", entryUid); - var result = GetInternalProperty(config, "EntryUID"); - - // Assert - Assert.Equal(entryUid, result); - } - - [Fact] - public void LivePreview_SetAndGet_InternalProperty() - { - // Arrange - var config = new LivePreviewConfig(); - var hash = _fixture.Create(); - - // Act - SetInternalProperty(config, "LivePreview", hash); - var result = GetInternalProperty(config, "LivePreview"); - - // Assert - Assert.Equal(hash, result); - } - - #endregion - - #region Basic Cache Method Tests - - [Fact] - public void IsCachedPreviewForCurrentQuery_NullPreviewResponse_ReturnsFalse() - { - // Arrange - var config = new LivePreviewConfig - { - PreviewTimestamp = "2024-11-29T14:30:00.000Z", - ReleaseId = "test_release" - }; - config.PreviewResponse = null; - - // Act - var result = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_AllValuesNull_WithPreviewResponse_ReturnsTrue() - { - // Arrange - var config = new LivePreviewConfig(); - config.PreviewResponse = JObject.Parse(@"{ ""entry"": { ""uid"": ""test"" } }"); - - // Ensure all values and fingerprints are null - config.PreviewTimestamp = null; - config.ReleaseId = null; - SetInternalProperty(config, "LivePreview", null); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Act - var result = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_SingleMatchingProperty_ReturnsTrue() - { - // Arrange - var config = new LivePreviewConfig - { - PreviewTimestamp = "2024-11-29T14:30:00.000Z" - }; - config.PreviewResponse = JObject.Parse(@"{ ""entry"": { ""uid"": ""test"" } }"); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - - // Act - var result = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_SingleNonMatchingProperty_ReturnsFalse() - { - // Arrange - var config = new LivePreviewConfig - { - PreviewTimestamp = "2024-11-29T14:30:00.000Z" - }; - config.PreviewResponse = JObject.Parse(@"{ ""entry"": { ""uid"": ""test"" } }"); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T10:00:00.000Z"); - - // Act - var result = config.IsCachedPreviewForCurrentQuery(); - - // Assert - Assert.False(result); - } - - #endregion - - #region Fingerprint Integration Tests - - [Fact] - public void FingerprintProperties_IndependentValues_NoInterference() - { - // Arrange - var config = new LivePreviewConfig(); - var timestamp = "2024-11-29T14:30:00.000Z"; - var releaseId = "test_release_123"; - var hash = "test_hash_456"; - - // Act - Set each fingerprint independently - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", timestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", releaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", hash); - - // Assert - Each maintains its own value - Assert.Equal(timestamp, GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Equal(releaseId, GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Equal(hash, GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - - // Modify one - others should be unaffected - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "modified_timestamp"); - - Assert.Equal("modified_timestamp", GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Equal(releaseId, GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Equal(hash, GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - } - - [Fact] - public void FingerprintProperties_NullValues_HandledCorrectly() - { - // Arrange - var config = new LivePreviewConfig(); - - // Act - Set to null - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", null); - - // Assert - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - } - - [Fact] - public void ContextProperties_IndependentValues_NoInterference() - { - // Arrange - var config = new LivePreviewConfig(); - var contentTypeUid = "test_ct_123"; - var entryUid = "test_entry_456"; - var livePreview = "test_hash_789"; - - // Act - SetInternalProperty(config, "ContentTypeUID", contentTypeUid); - SetInternalProperty(config, "EntryUID", entryUid); - SetInternalProperty(config, "LivePreview", livePreview); - - // Assert - Assert.Equal(contentTypeUid, GetInternalProperty(config, "ContentTypeUID")); - Assert.Equal(entryUid, GetInternalProperty(config, "EntryUID")); - Assert.Equal(livePreview, GetInternalProperty(config, "LivePreview")); - } - - #endregion - - #region Helper Methods - - /// - /// Sets internal property value using reflection (for testing internal state) - /// - private void SetInternalProperty(object target, string propertyName, object value) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - property?.SetValue(target, value); - } - - /// - /// Gets internal property value using reflection (for testing internal state) - /// - private T GetInternalProperty(object target, string propertyName) - { - var property = target.GetType().GetProperty(propertyName, - BindingFlags.NonPublic | BindingFlags.Instance); - return (T)property?.GetValue(target); - } - - #endregion } } diff --git a/Contentstack.Core.Unit.Tests/Mokes/TimelineMockHttpHandler.cs b/Contentstack.Core.Unit.Tests/Mokes/TimelineMockHttpHandler.cs deleted file mode 100644 index b5790591..00000000 --- a/Contentstack.Core.Unit.Tests/Mokes/TimelineMockHttpHandler.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Contentstack.Core.Interfaces; -using Newtonsoft.Json.Linq; - -namespace Contentstack.Core.Unit.Tests.Mokes -{ - /// - /// Mock HTTP handler specialized for Timeline Preview functionality testing - /// - public class TimelineMockHttpHandler : IContentstackPlugin - { - private TimeSpan _delay = TimeSpan.Zero; - private bool _shouldThrowTimeout = false; - private bool _shouldThrowWebException = false; - private string _webExceptionMessage = "Network error"; - private JObject _mockResponse; - private string _expectedEntryUid; - private string _expectedContentTypeUid; - - /// - /// List of requests that have been processed by this handler - /// - public List Requests { get; private set; } = new List(); - - /// - /// Configure handler to return successful live preview responses - /// - /// Expected entry UID in requests - /// Expected content type UID in requests - /// Handler instance for chaining - public TimelineMockHttpHandler ForSuccessfulLivePreview(string entryUid = "test_entry", string contentTypeUid = "test_ct") - { - _expectedEntryUid = entryUid; - _expectedContentTypeUid = contentTypeUid; - - _mockResponse = JObject.Parse($@"{{ - ""entry"": {{ - ""uid"": ""{entryUid}"", - ""content_type_uid"": ""{contentTypeUid}"", - ""title"": ""Timeline Mock Entry"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""2024-11-29T14:30:00.000Z"", - ""publish_details"": {{ - ""environment"": ""test"", - ""locale"": ""en-us"", - ""time"": ""2024-11-29T14:30:00.000Z"" - }}, - ""mock_field"": ""timeline_test_value"", - ""_metadata"": {{ - ""mock_handler"": true, - ""timestamp"": ""{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"" - }} - }} - }}"); - - return this; - } - - /// - /// Configure handler to return a custom live preview response - /// - /// Custom JObject response - /// Handler instance for chaining - public TimelineMockHttpHandler ForLivePreview(JObject response) - { - _mockResponse = response; - return this; - } - - /// - /// Configure handler to return timeline-specific response for different timestamps - /// - /// Timeline timestamp - /// Release ID - /// Entry UID - /// Content Type UID - /// Handler instance for chaining - public TimelineMockHttpHandler ForTimelineScenario(string timestamp, string releaseId = null, string entryUid = "test_entry", string contentTypeUid = "test_ct") - { - _expectedEntryUid = entryUid; - _expectedContentTypeUid = contentTypeUid; - - _mockResponse = JObject.Parse($@"{{ - ""entry"": {{ - ""uid"": ""{entryUid}"", - ""content_type_uid"": ""{contentTypeUid}"", - ""title"": ""Timeline Entry at {timestamp}"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""{timestamp}"", - ""publish_details"": {{ - ""environment"": ""test"", - ""locale"": ""en-us"", - ""time"": ""{timestamp}"", - ""release_id"": ""{releaseId ?? "default_release"}"" - }}, - ""timeline_content"": ""Content for {timestamp}"", - ""_metadata"": {{ - ""timeline_timestamp"": ""{timestamp}"", - ""timeline_release"": ""{releaseId ?? "default_release"}"", - ""mock_handler"": true - }} - }} - }}"); - - return this; - } - - /// - /// Configure handler to simulate network timeout - /// - /// Handler instance for chaining - public TimelineMockHttpHandler ThrowTimeout() - { - _shouldThrowTimeout = true; - return this; - } - - /// - /// Configure handler to simulate network error - /// - /// Error message - /// Handler instance for chaining - public TimelineMockHttpHandler ThrowWebException(string message = "Network error") - { - _shouldThrowWebException = true; - _webExceptionMessage = message; - return this; - } - - /// - /// Add artificial delay to simulate network latency - /// - /// Delay duration - /// Handler instance for chaining - public TimelineMockHttpHandler WithDelay(TimeSpan delay) - { - _delay = delay; - return this; - } - - /// - /// Handle HTTP request processing (IContentstackPlugin implementation) - /// - /// Contentstack client - /// HTTP request - /// Modified request - public async Task OnRequest(ContentstackClient stack, HttpWebRequest request) - { - // Track the request for testing purposes - Requests.Add(request); - - // Simulate delay if configured - if (_delay > TimeSpan.Zero) - { - await Task.Delay(_delay); - } - - // Simulate timeout if configured - if (_shouldThrowTimeout) - { - throw new TaskCanceledException("Request timeout"); - } - - // Just pass through the request (no modifications needed for our mock) - return await Task.FromResult(request); - } - - /// - /// Handle HTTP response processing (IContentstackPlugin implementation) - /// - /// Contentstack client - /// HTTP request - /// HTTP response - /// Original response string - /// Mock response string - public async Task OnResponse(ContentstackClient stack, HttpWebRequest request, HttpWebResponse response, string responseString) - { - // Simulate web exception if configured - if (_shouldThrowWebException) - { - throw new WebException(_webExceptionMessage); - } - - // Return mock response instead of actual response - if (_mockResponse != null) - { - return await Task.FromResult(_mockResponse.ToString()); - } - - // Default fallback response - return await Task.FromResult(CreateDefaultResponse()); - } - - /// - /// Create multiple timeline responses for comparison testing - /// - /// Array of timestamps - /// Entry UID - /// Content Type UID - /// Handler configured for multiple scenarios - public TimelineMockHttpHandler ForMultipleTimelines(string[] timestamps, string entryUid = "test_entry", string contentTypeUid = "test_ct") - { - // For simplicity, this returns the first timestamp scenario - // In a more sophisticated implementation, this could track request parameters - // and return different responses based on the request context - if (timestamps != null && timestamps.Length > 0) - { - return ForTimelineScenario(timestamps[0], null, entryUid, contentTypeUid); - } - - return ForSuccessfulLivePreview(entryUid, contentTypeUid); - } - - /// - /// Configure for cache testing scenarios - /// - /// Unique fingerprint for this response - /// Handler instance for chaining - public TimelineMockHttpHandler ForCacheScenario(string fingerprint) - { - _mockResponse = JObject.Parse($@"{{ - ""entry"": {{ - ""uid"": ""cache_test_entry"", - ""content_type_uid"": ""cache_test_ct"", - ""title"": ""Cache Test Entry"", - ""cache_fingerprint"": ""{fingerprint}"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""2024-11-29T14:30:00.000Z"", - ""_metadata"": {{ - ""cache_test"": true, - ""fingerprint"": ""{fingerprint}"" - }} - }} - }}"); - - return this; - } - - private string CreateDefaultResponse() - { - return JObject.Parse(@"{ - ""entry"": { - ""uid"": ""default_entry"", - ""content_type_uid"": ""default_ct"", - ""title"": ""Default Mock Entry"", - ""created_at"": ""2024-11-29T10:00:00.000Z"", - ""updated_at"": ""2024-11-29T14:30:00.000Z"", - ""_metadata"": { - ""default_response"": true - } - } - }").ToString(); - } - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/TimelineCacheBehaviorTests.cs b/Contentstack.Core.Unit.Tests/TimelineCacheBehaviorTests.cs deleted file mode 100644 index 8b030c48..00000000 --- a/Contentstack.Core.Unit.Tests/TimelineCacheBehaviorTests.cs +++ /dev/null @@ -1,561 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Models; -using Contentstack.Core.Unit.Tests.Helpers; -using Contentstack.Core.Unit.Tests.Mokes; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Unit tests for Timeline Preview cache behavior and performance - /// Tests fingerprint-based caching, cache hits/misses, and performance optimizations - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Cache")] - [Trait("Category", "Performance")] - public class TimelineCacheBehaviorTests : ContentstackClientTestBase - { - #region Cache Hit Performance - - [Fact] - public async Task CacheHit_SignificantlyFasterThanNetworkRequest() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set up cached response - var cachedResponse = TimelineTestDataBuilder.New() - .CreateValidPreviewResponse("perf_entry", "perf_ct"); - - config.PreviewResponse = cachedResponse; - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - config.ReleaseId = "perf_release"; - - SetInternalProperty(config, "ContentTypeUID", "perf_ct"); - SetInternalProperty(config, "EntryUID", "perf_entry"); - - // Set matching fingerprints for cache hit - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", config.ReleaseId); - - // Create query that should hit cache - var cacheHitQuery = CreateLivePreviewQuery( - contentTypeUid: "perf_ct", - entryUid: "perf_entry", - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "perf_release" - ); - - // Act & Assert - Cache operation should be fast - await TimelinePerformanceHelpers.AssertCachePerformance( - cacheOperation: async () => - { - // This should hit cache - test the cache checking logic directly - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - - // Access cached response - var result = config.PreviewResponse; - Assert.NotNull(result); - - // Small delay to simulate cache access overhead - await Task.Delay(1); - }, - networkOperation: async () => - { - // Simulate realistic network delay - await Task.Delay(50); - var result = CreateMockPreviewResponse(); - Assert.NotNull(result); - }, - description: "Timeline Preview Cache Hit", - minimumSpeedupFactor: 3 - ); - } - - [Fact] - public void CacheHit_MultipleRequests_ConsistentPerformance() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("consistent_release") - .WithPreviewResponse() - .WithMatchingFingerprints() - .Build(); - - var iterations = 1000; - - // Act & Assert - Multiple cache checks should be consistently fast - TimelinePerformanceHelpers.AssertExecutionTime(() => - { - for (int i = 0; i < iterations; i++) - { - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - } - }, TimeSpan.FromMilliseconds(10), $"{iterations} cache hit checks"); - } - - [Fact] - public void CacheHit_MemoryEfficient_NoLeaks() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .WithMatchingFingerprints() - .Build(); - - // Act & Assert - Multiple cache hits should not leak memory - TimelinePerformanceHelpers.AssertNoMemoryLeak(() => - { - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - }, iterations: 10000, maxMemoryGrowth: 1024 * 1024); // 1MB max growth - } - - #endregion - - #region Cache Miss Behavior - - [Fact] - public void CacheMiss_FingerprintMismatch_CorrectDetection() - { - // Arrange - Set up cache miss scenarios - var scenarios = new[] - { - // Timestamp mismatch - TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .Build(), - - // Release ID mismatch - TimelineTestDataBuilder.New() - .WithReleaseId("current_release") - .WithPreviewResponse() - .Build(), - - // Live preview hash mismatch - TimelineTestDataBuilder.New() - .WithLivePreview("current_hash") - .WithPreviewResponse() - .Build() - }; - - // Set mismatched fingerprints - SetInternalProperty(scenarios[0], "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T10:00:00.000Z"); - SetInternalProperty(scenarios[1], "PreviewResponseFingerprintReleaseId", "old_release"); - SetInternalProperty(scenarios[2], "PreviewResponseFingerprintLivePreview", "old_hash"); - - // Act & Assert - All should detect cache miss - foreach (var config in scenarios) - { - Assert.False(config.IsCachedPreviewForCurrentQuery()); - } - } - - [Fact] - public void CacheMiss_NullResponse_AlwaysMiss() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release") - .WithMatchingFingerprints() - .Build(); - - config.PreviewResponse = null; - - // Act & Assert - Null response should always be cache miss - Assert.False(config.IsCachedPreviewForCurrentQuery()); - } - - [Fact] - public void CacheMiss_PartialFingerprints_CorrectBehavior() - { - // Arrange - Test partial fingerprint scenarios - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("test_release") - .WithLivePreview("test_hash") - .WithPreviewResponse() - .Build(); - - var testCases = new[] - { - new { Description = "Only timestamp fingerprint set", SetTimestamp = true, SetRelease = false, SetHash = false }, - new { Description = "Only release fingerprint set", SetTimestamp = false, SetRelease = true, SetHash = false }, - new { Description = "Only hash fingerprint set", SetTimestamp = false, SetRelease = false, SetHash = true }, - new { Description = "Timestamp and release fingerprints set", SetTimestamp = true, SetRelease = true, SetHash = false }, - new { Description = "Timestamp and hash fingerprints set", SetTimestamp = true, SetRelease = false, SetHash = true }, - new { Description = "Release and hash fingerprints set", SetTimestamp = false, SetRelease = true, SetHash = true } - }; - - foreach (var testCase in testCases) - { - // Set fingerprints based on test case - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", - testCase.SetTimestamp ? config.PreviewTimestamp : null); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", - testCase.SetRelease ? config.ReleaseId : null); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", - testCase.SetHash ? GetInternalProperty(config, "LivePreview") : null); - - // Act & Assert - var isCached = config.IsCachedPreviewForCurrentQuery(); - - // Should only be cache hit if ALL non-null current values match their fingerprints - bool expectedCacheHit = true; - if (!string.IsNullOrEmpty(config.PreviewTimestamp) && !testCase.SetTimestamp) expectedCacheHit = false; - if (!string.IsNullOrEmpty(config.ReleaseId) && !testCase.SetRelease) expectedCacheHit = false; - if (!string.IsNullOrEmpty(GetInternalProperty(config, "LivePreview")) && !testCase.SetHash) expectedCacheHit = false; - - Assert.True(expectedCacheHit == isCached, testCase.Description); - } - } - - #endregion - - #region Cache Fingerprint Updates - - [Fact] - public async Task CacheFingerprint_UpdatedAfterSuccessfulQuery() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler() - .ForLivePreview(JObject.Parse(TimelineMockHelpers.CreateMockLivePreviewResponse())) - .WithDelay(TimeSpan.FromMilliseconds(50)); // Predictable delay - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery( - contentTypeUid: "fingerprint_ct", - entryUid: "fingerprint_entry", - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "test_release" - ); - - var config = client.GetLivePreviewConfig(); - - // Verify initial state (no fingerprints) - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Verify that config values are properly set from the query - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); - Assert.Equal("test_release", config.ReleaseId); - Assert.Equal("fingerprint_ct", config.ContentTypeUID); - Assert.Equal("fingerprint_entry", config.EntryUID); - - // Verify that a mock request was made - Assert.True(mockHandler.Requests.Count > 0); - } - - [Fact] - public void CacheFingerprint_ResetClearsFingerprints() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T14:30:00.000Z"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "test_release"); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "test_hash"); - - // Act - client.ResetLivePreview(); - - // Assert - Fingerprints should be cleared - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintReleaseId")); - Assert.Null(GetInternalProperty(config, "PreviewResponseFingerprintLivePreview")); - } - - [Fact] - public void CacheFingerprint_ForkPreservesFingerprints() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var parentConfig = parentClient.GetLivePreviewConfig(); - - // Set parent fingerprints - SetInternalProperty(parentConfig, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T14:30:00.000Z"); - SetInternalProperty(parentConfig, "PreviewResponseFingerprintReleaseId", "parent_release"); - SetInternalProperty(parentConfig, "PreviewResponseFingerprintLivePreview", "parent_hash"); - - // Act - var forkedClient = parentClient.Fork(); - var forkedConfig = forkedClient.GetLivePreviewConfig(); - - // Assert - Fingerprints should be preserved in fork - Assert.Equal("2024-11-29T14:30:00.000Z", - GetInternalProperty(forkedConfig, "PreviewResponseFingerprintPreviewTimestamp")); - Assert.Equal("parent_release", - GetInternalProperty(forkedConfig, "PreviewResponseFingerprintReleaseId")); - Assert.Equal("parent_hash", - GetInternalProperty(forkedConfig, "PreviewResponseFingerprintLivePreview")); - - // But they should be independent instances - SetInternalProperty(forkedConfig, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-30T00:00:00.000Z"); - - // Parent should be unchanged - Assert.Equal("2024-11-29T14:30:00.000Z", - GetInternalProperty(parentConfig, "PreviewResponseFingerprintPreviewTimestamp")); - } - - #endregion - - #region Cache Performance Optimization - - [Fact] - public void Cache_OptimizedStringComparison_Performance() - { - // Arrange - var longTimestamp = "2024-11-29T14:30:00.000Z" + new string('x', 1000); - var longReleaseId = "release_" + new string('y', 1000); - var longHash = "hash_" + new string('z', 1000); - - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp(longTimestamp) - .WithReleaseId(longReleaseId) - .WithLivePreview(longHash) - .WithPreviewResponse() - .Build(); - - // Set matching fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", longTimestamp); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", longReleaseId); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", longHash); - - var iterations = 1000; - - // Act & Assert - Even with long strings, should be fast - TimelinePerformanceHelpers.AssertExecutionTime(() => - { - for (int i = 0; i < iterations; i++) - { - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - } - }, TimeSpan.FromMilliseconds(50), $"{iterations} long string comparisons"); - } - - [Fact] - public void Cache_ConcurrentAccess_ThreadSafe() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithReleaseId("concurrent_release") - .WithPreviewResponse() - .WithMatchingFingerprints() - .Build(); - - var numberOfThreads = 10; - var operationsPerThread = 100; - var allResults = new List(); - var lockObject = new object(); - - // Act - Concurrent cache checks - var tasks = new Task[numberOfThreads]; - for (int threadId = 0; threadId < numberOfThreads; threadId++) - { - tasks[threadId] = Task.Run(() => - { - var threadResults = new List(); - for (int i = 0; i < operationsPerThread; i++) - { - var isCached = config.IsCachedPreviewForCurrentQuery(); - threadResults.Add(isCached); - } - - lock (lockObject) - { - allResults.AddRange(threadResults); - } - }); - } - - Task.WaitAll(tasks); - - // Assert - All operations should return consistent results - var expectedResults = numberOfThreads * operationsPerThread; - Assert.Equal(expectedResults, allResults.Count); - Assert.All(allResults, result => Assert.True(result)); - } - - #endregion - - #region Entry.Fetch Integration - - [Fact] - public async Task EntryFetch_CacheHit_SkipsNetworkRequest() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set up cached response - var cachedResponse = TimelineTestDataBuilder.New() - .CreateValidPreviewResponse("fetch_entry", "fetch_ct"); - - config.PreviewResponse = cachedResponse; - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - - SetInternalProperty(config, "ContentTypeUID", "fetch_ct"); - SetInternalProperty(config, "EntryUID", "fetch_entry"); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - - var contentType = client.ContentType("fetch_ct"); - var entry = contentType.Entry("fetch_entry"); - - var mockHandler = new TimelineMockHttpHandler() - .ForSuccessfulLivePreview("fetch_entry", "fetch_ct"); - - // Don't add handler to client - if cache works, no network call should be made - - // Act & Assert - Should use cache, not make network request - var result = await entry.Fetch(); - - Assert.NotNull(result); - // Should return the cached response data - Assert.Equal("fetch_entry", result["uid"]?.ToString()); - } - - [Fact] - public async Task LivePreviewQuery_CacheMiss_AttemptsNetworkRequest() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set up cache miss scenario - set non-matching fingerprints - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T10:00:00.000Z"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "old_release"); - - var mockHandler = new TimelineMockHttpHandler() - .ForLivePreview(JObject.Parse(TimelineMockHelpers.CreateMockLivePreviewResponse())); - client.Plugins.Add(mockHandler); - - // Create query that will cause cache miss - var query = CreateLivePreviewQuery( - contentTypeUid: "network_ct", - entryUid: "network_entry", - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "network_release" - ); - - // Act - await client.LivePreviewQueryAsync(query); - - // Assert - Should have attempted network request due to cache miss - Assert.NotEmpty(mockHandler.Requests); - - // Verify cache configuration was updated with new values - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); - Assert.Equal("network_release", config.ReleaseId); - Assert.Equal("network_ct", config.ContentTypeUID); - Assert.Equal("network_entry", config.EntryUID); - } - - #endregion - - #region Cache Size and Memory Management - - [Fact] - public void Cache_LargeResponses_MemoryEfficient() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithMatchingFingerprints() - .Build(); - - // Create large response - var largeResponseData = JObject.Parse($@"{{ - ""entry"": {{ - ""uid"": ""large_entry"", - ""title"": ""Large Test Entry"", - ""large_field_1"": ""{new string('a', 10000)}"", - ""large_field_2"": ""{new string('b', 10000)}"", - ""large_field_3"": ""{new string('c', 10000)}"", - ""array_field"": [{string.Join(",", Enumerable.Range(0, 1000).Select(i => $"\"{i}\""))}] - }} - }}"); - - config.PreviewResponse = largeResponseData; - - // Act & Assert - Cache operations should be memory efficient - TimelinePerformanceHelpers.AssertNoMemoryLeak(() => - { - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - }, iterations: 1000, maxMemoryGrowth: 512 * 1024); // 512KB max growth for large object caching - } - - [Fact] - public void Cache_MultipleEntries_IndependentCaching() - { - // Arrange - var baseClient = CreateClientWithLivePreview(); - var numberOfEntries = 50; - var configs = new LivePreviewConfig[numberOfEntries]; - - // Set up different cache states for multiple entries - for (int i = 0; i < numberOfEntries; i++) - { - var fork = baseClient.Fork(); - configs[i] = fork.GetLivePreviewConfig(); - - configs[i].PreviewTimestamp = $"2024-11-{(i % 12) + 1:D2}-01T00:00:00.000Z"; - configs[i].ReleaseId = $"release_{i}"; - configs[i].PreviewResponse = TimelineTestDataBuilder.New() - .CreateValidPreviewResponse($"entry_{i}", $"ct_{i}"); - - SetInternalProperty(configs[i], "ContentTypeUID", $"ct_{i}"); - SetInternalProperty(configs[i], "EntryUID", $"entry_{i}"); - SetInternalProperty(configs[i], "PreviewResponseFingerprintPreviewTimestamp", configs[i].PreviewTimestamp); - SetInternalProperty(configs[i], "PreviewResponseFingerprintReleaseId", configs[i].ReleaseId); - } - - // Act & Assert - Each entry should have independent cache behavior - for (int i = 0; i < numberOfEntries; i++) - { - Assert.True(configs[i].IsCachedPreviewForCurrentQuery(), $"Entry {i} should be cached"); - - // Modify one entry's timestamp - only that entry should become cache miss - var originalTimestamp = configs[i].PreviewTimestamp; - configs[i].PreviewTimestamp = "2024-12-01T00:00:00.000Z"; - Assert.False(configs[i].IsCachedPreviewForCurrentQuery(), $"Entry {i} should be cache miss after timestamp change"); - - // Restore timestamp - configs[i].PreviewTimestamp = originalTimestamp; - Assert.True(configs[i].IsCachedPreviewForCurrentQuery(), $"Entry {i} should be cached again after timestamp restore"); - - // Verify other entries are unaffected - for (int j = 0; j < numberOfEntries; j++) - { - if (j != i) - { - Assert.True(configs[j].IsCachedPreviewForCurrentQuery(), $"Entry {j} should remain cached when entry {i} is modified"); - } - } - } - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/TimelineIsolationTests.cs b/Contentstack.Core.Unit.Tests/TimelineIsolationTests.cs deleted file mode 100644 index 235d6976..00000000 --- a/Contentstack.Core.Unit.Tests/TimelineIsolationTests.cs +++ /dev/null @@ -1,540 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Unit.Tests.Helpers; -using Contentstack.Core.Unit.Tests.Mokes; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Unit tests focused on Timeline Preview isolation behavior - /// Tests fork independence, parallel operations, and state isolation - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "Isolation")] - [Trait("Category", "Parallel")] - public class TimelineIsolationTests : ContentstackClientTestBase - { - #region Fork Independence - - [Fact] - public void ForkIsolation_IndependentTimelineContexts() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - var fork3 = parentClient.Fork(); - - // Act - Set different timeline contexts - parentClient.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T08:00:00.000Z"; - parentClient.GetLivePreviewConfig().ReleaseId = "parent_release"; - - fork1.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - fork1.GetLivePreviewConfig().ReleaseId = "fork1_release"; - - fork2.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T12:00:00.000Z"; - fork2.GetLivePreviewConfig().ReleaseId = "fork2_release"; - - fork3.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:00:00.000Z"; - fork3.GetLivePreviewConfig().ReleaseId = "fork3_release"; - - // Assert - Each maintains its own timeline context - Assert.Equal("2024-11-29T08:00:00.000Z", parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("parent_release", parentClient.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T10:00:00.000Z", fork1.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("fork1_release", fork1.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T12:00:00.000Z", fork2.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("fork2_release", fork2.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T14:00:00.000Z", fork3.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("fork3_release", fork3.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public async Task ForkIsolation_ParallelLivePreviewQueryOperations() - { - // Arrange - var parentClient = CreateClientWithLivePreview(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - var fork3 = parentClient.Fork(); - - var query1 = CreateLivePreviewQuery( - entryUid: "entry1", - previewTimestamp: "2024-11-29T08:00:00.000Z", - releaseId: "release1" - ); - - var query2 = CreateLivePreviewQuery( - entryUid: "entry2", - previewTimestamp: "2024-11-29T12:00:00.000Z", - releaseId: "release2" - ); - - var query3 = CreateLivePreviewQuery( - entryUid: "entry3", - previewTimestamp: "2024-11-29T16:00:00.000Z", - releaseId: "release3" - ); - - // Act - Parallel operations - var tasks = new[] - { - fork1.LivePreviewQueryAsync(query1), - fork2.LivePreviewQueryAsync(query2), - fork3.LivePreviewQueryAsync(query3) - }; - - await Task.WhenAll(tasks); - - // Assert - Each fork maintains its context without interference - Assert.Equal("2024-11-29T08:00:00.000Z", fork1.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("release1", fork1.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T12:00:00.000Z", fork2.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("release2", fork2.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("2024-11-29T16:00:00.000Z", fork3.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("release3", fork3.GetLivePreviewConfig().ReleaseId); - - // Parent should remain unaffected - Assert.Null(parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Null(parentClient.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void ForkIsolation_IndependentCacheHitMissBehavior() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - - // Set up fork1 with cached data (cache hit scenario) - var fork1Config = fork1.GetLivePreviewConfig(); - fork1Config.PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - fork1Config.ReleaseId = "cached_release"; - fork1Config.PreviewResponse = CreateMockPreviewResponse("entry1", "ct1"); - - SetInternalProperty(fork1Config, "PreviewResponseFingerprintPreviewTimestamp", fork1Config.PreviewTimestamp); - SetInternalProperty(fork1Config, "PreviewResponseFingerprintReleaseId", fork1Config.ReleaseId); - - // Set up fork2 with different data (cache miss scenario) - var fork2Config = fork2.GetLivePreviewConfig(); - fork2Config.PreviewTimestamp = "2024-11-29T14:00:00.000Z"; - fork2Config.ReleaseId = "different_release"; - fork2Config.PreviewResponse = CreateMockPreviewResponse("entry2", "ct2"); - - SetInternalProperty(fork2Config, "PreviewResponseFingerprintPreviewTimestamp", "2024-11-29T08:00:00.000Z"); // Different - SetInternalProperty(fork2Config, "PreviewResponseFingerprintReleaseId", "old_release"); // Different - - // Act & Assert - Assert.True(fork1Config.IsCachedPreviewForCurrentQuery()); // Cache hit - Assert.False(fork2Config.IsCachedPreviewForCurrentQuery()); // Cache miss - - // Verify independence - changes to one don't affect the other - fork1Config.PreviewTimestamp = "2024-11-29T20:00:00.000Z"; - Assert.False(fork1Config.IsCachedPreviewForCurrentQuery()); // Now cache miss for fork1 - Assert.False(fork2Config.IsCachedPreviewForCurrentQuery()); // Still cache miss for fork2 - } - - [Fact] - public void ForkIsolation_ResetLivePreview_DoesNotAffectOtherForks() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - var fork3 = parentClient.Fork(); - - // Set up different states - parentClient.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T08:00:00.000Z"; - fork1.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - fork2.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T12:00:00.000Z"; - fork3.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:00:00.000Z"; - - // Act - Reset only fork2 - fork2.ResetLivePreview(); - - // Assert - Only fork2 is affected - Assert.Equal("2024-11-29T08:00:00.000Z", parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("2024-11-29T10:00:00.000Z", fork1.GetLivePreviewConfig().PreviewTimestamp); - Assert.Null(fork2.GetLivePreviewConfig().PreviewTimestamp); // Reset - Assert.Equal("2024-11-29T14:00:00.000Z", fork3.GetLivePreviewConfig().PreviewTimestamp); - } - - #endregion - - #region Concurrent Modifications - - [Fact] - public void ConcurrentModifications_IndependentStates() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var numberOfForks = 10; - var forks = new ContentstackClient[numberOfForks]; - - for (int i = 0; i < numberOfForks; i++) - { - forks[i] = parentClient.Fork(); - } - - // Act - Concurrent modifications - var tasks = new Task[numberOfForks]; - for (int i = 0; i < numberOfForks; i++) - { - int index = i; // Capture for closure - tasks[i] = Task.Run(() => - { - var config = forks[index].GetLivePreviewConfig(); - config.PreviewTimestamp = $"2024-11-{(index % 12) + 1:D2}-01T{index:D2}:00:00.000Z"; - config.ReleaseId = $"release_{index}"; - - // Simulate some work - Thread.Sleep(10); - - // Modify again - config.PreviewTimestamp = $"2024-11-{(index % 12) + 1:D2}-01T{index:D2}:30:00.000Z"; - }); - } - - Task.WaitAll(tasks); - - // Assert - Each fork should have its final state - for (int i = 0; i < numberOfForks; i++) - { - var expectedTimestamp = $"2024-11-{(i % 12) + 1:D2}-01T{i:D2}:30:00.000Z"; - var expectedReleaseId = $"release_{i}"; - - Assert.Equal(expectedTimestamp, forks[i].GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal(expectedReleaseId, forks[i].GetLivePreviewConfig().ReleaseId); - } - } - - [Fact] - public void HighVolume_ParallelForkOperations_NoStateCorruption() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var operationsPerThread = 100; - var numberOfThreads = 10; - var allResults = new ConcurrentBag(); - - // Act - High volume parallel operations - var tasks = new Task[numberOfThreads]; - for (int threadId = 0; threadId < numberOfThreads; threadId++) - { - int currentThreadId = threadId; // Capture for closure - tasks[threadId] = Task.Run(() => - { - for (int i = 0; i < operationsPerThread; i++) - { - try - { - var fork = parentClient.Fork(); - var timestamp = $"2024-11-29T{currentThreadId:D2}:{i % 60:D2}:00.000Z"; - var releaseId = $"thread_{currentThreadId}_op_{i}"; - - fork.GetLivePreviewConfig().PreviewTimestamp = timestamp; - fork.GetLivePreviewConfig().ReleaseId = releaseId; - - // Verify state immediately - var actualTimestamp = fork.GetLivePreviewConfig().PreviewTimestamp; - var actualReleaseId = fork.GetLivePreviewConfig().ReleaseId; - - if (actualTimestamp == timestamp && actualReleaseId == releaseId) - { - allResults.Add($"SUCCESS_{currentThreadId}_{i}"); - } - else - { - allResults.Add($"CORRUPTION_{currentThreadId}_{i}_{actualTimestamp}_{actualReleaseId}"); - } - } - catch (Exception ex) - { - allResults.Add($"EXCEPTION_{currentThreadId}_{i}_{ex.Message}"); - } - } - }); - } - - Task.WaitAll(tasks); - - // Assert - No state corruption or exceptions - var results = allResults.ToList(); - var expectedResultCount = numberOfThreads * operationsPerThread; - Assert.Equal(expectedResultCount, results.Count); - - var successResults = results.Where(r => r.StartsWith("SUCCESS")).ToList(); - var corruptionResults = results.Where(r => r.StartsWith("CORRUPTION")).ToList(); - var exceptionResults = results.Where(r => r.StartsWith("EXCEPTION")).ToList(); - - Assert.Equal(expectedResultCount, successResults.Count); - Assert.Empty(corruptionResults); - Assert.Empty(exceptionResults); - } - - #endregion - - #region Cache Operations Isolation - - [Fact] - public void ConcurrentCacheOperations_IsolatedBehavior() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var fork1 = parentClient.Fork(); - var fork2 = parentClient.Fork(); - var fork3 = parentClient.Fork(); - - // Set up different cache scenarios - var response1 = CreateMockPreviewResponse("entry1", "ct1"); - var response2 = CreateMockPreviewResponse("entry2", "ct2"); - var response3 = CreateMockPreviewResponse("entry3", "ct3"); - - // Act - Concurrent cache operations - var tasks = new[] - { - Task.Run(() => - { - var config1 = fork1.GetLivePreviewConfig(); - config1.PreviewTimestamp = "2024-11-29T10:00:00.000Z"; - config1.PreviewResponse = response1; - SetInternalProperty(config1, "PreviewResponseFingerprintPreviewTimestamp", config1.PreviewTimestamp); - SetInternalProperty(config1, "PreviewResponseFingerprintReleaseId", config1.ReleaseId); - SetInternalProperty(config1, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config1, "LivePreview")); - }), - Task.Run(() => - { - var config2 = fork2.GetLivePreviewConfig(); - config2.ReleaseId = "concurrent_release"; - config2.PreviewResponse = response2; - SetInternalProperty(config2, "PreviewResponseFingerprintPreviewTimestamp", config2.PreviewTimestamp); - SetInternalProperty(config2, "PreviewResponseFingerprintReleaseId", config2.ReleaseId); - SetInternalProperty(config2, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config2, "LivePreview")); - }), - Task.Run(() => - { - var config3 = fork3.GetLivePreviewConfig(); - SetInternalProperty(config3, "LivePreview", "concurrent_hash"); - config3.PreviewResponse = response3; - SetInternalProperty(config3, "PreviewResponseFingerprintPreviewTimestamp", config3.PreviewTimestamp); - SetInternalProperty(config3, "PreviewResponseFingerprintReleaseId", config3.ReleaseId); - SetInternalProperty(config3, "PreviewResponseFingerprintLivePreview", GetInternalProperty(config3, "LivePreview")); - }) - }; - - Task.WaitAll(tasks); - - // Assert - Each fork has its own cache state - Assert.Same(response1, fork1.GetLivePreviewConfig().PreviewResponse); - Assert.True(fork1.GetLivePreviewConfig().IsCachedPreviewForCurrentQuery()); - - Assert.Same(response2, fork2.GetLivePreviewConfig().PreviewResponse); - Assert.True(fork2.GetLivePreviewConfig().IsCachedPreviewForCurrentQuery()); - - Assert.Same(response3, fork3.GetLivePreviewConfig().PreviewResponse); - Assert.True(fork3.GetLivePreviewConfig().IsCachedPreviewForCurrentQuery()); - - // Cross-verification - each fork doesn't match others' fingerprints - fork1.GetLivePreviewConfig().ReleaseId = "concurrent_release"; - Assert.False(fork1.GetLivePreviewConfig().IsCachedPreviewForCurrentQuery()); // Different fingerprint - } - - #endregion - - #region Error Isolation - - [Fact] - public void ErrorIsolation_InvalidTimestamp_DoesNotAffectOtherForks() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var validFork = parentClient.Fork(); - var invalidFork = parentClient.Fork(); - - // Set up valid state for validFork - validFork.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - validFork.GetLivePreviewConfig().ReleaseId = "valid_release"; - - // Act - Set invalid timestamp on invalidFork - invalidFork.GetLivePreviewConfig().PreviewTimestamp = "invalid-timestamp-format"; - invalidFork.GetLivePreviewConfig().ReleaseId = "invalid_release"; - - // Assert - validFork is unaffected by invalid state in invalidFork - Assert.Equal("2024-11-29T14:30:00.000Z", validFork.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("valid_release", validFork.GetLivePreviewConfig().ReleaseId); - - Assert.Equal("invalid-timestamp-format", invalidFork.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("invalid_release", invalidFork.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void ErrorIsolation_ExceptionInOneFork_DoesNotCorruptOthers() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var stableFork = parentClient.Fork(); - var unstableFork = parentClient.Fork(); - - // Set up stable state - stableFork.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - stableFork.GetLivePreviewConfig().PreviewResponse = CreateMockPreviewResponse(); - - // Act & Assert - Exception in unstable fork doesn't affect stable fork - var stableTask = Task.Run(() => - { - // Stable operations - for (int i = 0; i < 100; i++) - { - stableFork.GetLivePreviewConfig().PreviewTimestamp = $"2024-11-29T14:{i % 60:D2}:00.000Z"; - Assert.NotNull(stableFork.GetLivePreviewConfig().PreviewTimestamp); - } - }); - - var unstableTask = Task.Run(() => - { - // Potentially problematic operations - try - { - for (int i = 0; i < 100; i++) - { - if (i == 50) - { - // Simulate error condition - throw new InvalidOperationException("Simulated error in unstable fork"); - } - unstableFork.GetLivePreviewConfig().ReleaseId = $"unstable_release_{i}"; - } - } - catch (InvalidOperationException) - { - // Expected exception - should not affect stable fork - } - }); - - Task.WaitAll(stableTask, unstableTask); - - // Assert - Stable fork completed successfully - Assert.NotNull(stableFork.GetLivePreviewConfig().PreviewTimestamp); - Assert.True(stableFork.GetLivePreviewConfig().PreviewTimestamp.StartsWith("2024-11-29T14:")); - - // Unstable fork state might be partially set, but stable fork is unaffected - Assert.NotEqual(stableFork.GetLivePreviewConfig().ReleaseId, - unstableFork.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void ErrorIsolation_NullReferenceInOneFork_IsolatedFailure() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var workingFork = parentClient.Fork(); - var problematicFork = parentClient.Fork(); - - // Set up working state - workingFork.GetLivePreviewConfig().PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - - // Act - Cause null reference in problematic fork - SetInternalProperty(problematicFork, "LivePreviewConfig", null); - - // Assert - Working fork should continue functioning - Assert.Equal("2024-11-29T14:30:00.000Z", workingFork.GetLivePreviewConfig().PreviewTimestamp); - - // Operations on working fork should continue to work - workingFork.GetLivePreviewConfig().ReleaseId = "still_working"; - Assert.Equal("still_working", workingFork.GetLivePreviewConfig().ReleaseId); - - // Problematic fork operations might throw, but don't affect working fork - var exception = Record.Exception(() => problematicFork.ResetLivePreview()); - // Exception is acceptable for problematic fork, but working fork remains unaffected - - Assert.Equal("2024-11-29T14:30:00.000Z", workingFork.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("still_working", workingFork.GetLivePreviewConfig().ReleaseId); - } - - #endregion - - #region Memory and Resource Isolation - - [Fact] - public void ResourceIsolation_IndependentMemoryFootprint() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var numberOfForks = 100; - var forks = new ContentstackClient[numberOfForks]; - - // Create large response objects to test memory isolation - var largeResponse = JObject.Parse($@"{{ - ""entry"": {{ - ""uid"": ""large_entry"", - ""large_field"": ""{new string('x', 1000)}"", - ""another_large_field"": ""{new string('y', 1000)}"" - }} - }}"); - - // Act - Create forks with independent large objects - for (int i = 0; i < numberOfForks; i++) - { - forks[i] = parentClient.Fork(); - forks[i].GetLivePreviewConfig().PreviewResponse = JObject.Parse(largeResponse.ToString()); - forks[i].GetLivePreviewConfig().PreviewTimestamp = $"2024-11-29T{i % 24:D2}:00:00.000Z"; - } - - // Assert - Each fork should have its own copy - for (int i = 0; i < numberOfForks; i++) - { - Assert.NotNull(forks[i].GetLivePreviewConfig().PreviewResponse); - Assert.Equal($"2024-11-29T{i % 24:D2}:00:00.000Z", - forks[i].GetLivePreviewConfig().PreviewTimestamp); - } - - // Modify one fork's response - others should be unaffected - forks[0].GetLivePreviewConfig().PreviewResponse["entry"]["uid"] = "modified_entry"; - - for (int i = 1; i < Math.Min(10, numberOfForks); i++) // Check first 10 for performance - { - Assert.Equal("large_entry", - forks[i].GetLivePreviewConfig().PreviewResponse["entry"]["uid"].ToString()); - } - } - - #endregion - } - - /// - /// Thread-safe collection for concurrent testing - /// - public class ConcurrentBag : List - { - private readonly object _lock = new object(); - - public new void Add(T item) - { - lock (_lock) - { - base.Add(item); - } - } - - public new List ToList() - { - lock (_lock) - { - return new List(this); - } - } - } -} \ No newline at end of file diff --git a/Contentstack.Core.Unit.Tests/TimelinePreviewErrorHandlingTests.cs b/Contentstack.Core.Unit.Tests/TimelinePreviewErrorHandlingTests.cs deleted file mode 100644 index 5d08b65b..00000000 --- a/Contentstack.Core.Unit.Tests/TimelinePreviewErrorHandlingTests.cs +++ /dev/null @@ -1,604 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using Contentstack.Core.Configuration; -using Contentstack.Core.Models; -using Contentstack.Core.Unit.Tests.Helpers; -using Contentstack.Core.Unit.Tests.Mokes; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - /// - /// Comprehensive error handling tests for Timeline Preview functionality - /// Tests exception scenarios, error isolation, and graceful degradation - /// - [Trait("Category", "TimelinePreview")] - [Trait("Category", "ErrorHandling")] - [Trait("Category", "Exceptions")] - public class TimelinePreviewErrorHandlingTests : ContentstackClientTestBase - { - #region Invalid Configuration Errors - - [Fact] - public void Fork_NullConfiguration_HandlesGracefully() - { - // Arrange - var client = CreateClient(); - SetInternalProperty(client, "LivePreviewConfig", null); - - // Act & Assert - Should not throw - var exception = Record.Exception(() => - { - var fork = client.Fork(); - Assert.NotNull(fork); - }); - - Assert.Null(exception); - } - - [Fact] - public void ResetLivePreview_CorruptedConfiguration_HandlesGracefully() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Corrupt the configuration - config.PreviewTimestamp = "invalid-timestamp-format!@#$%"; - config.ReleaseId = null; - config.PreviewResponse = JObject.Parse("{}"); // Empty/invalid response - - // Act & Assert - Should not throw - var exception = Record.Exception(() => client.ResetLivePreview()); - Assert.Null(exception); - - // Assert - Configuration should be cleared despite corruption - Assert.Null(config.PreviewTimestamp); - Assert.Null(config.PreviewResponse); - } - - [Fact] - public void IsCachedPreviewForCurrentQuery_CorruptedFingerprints_HandlesGracefully() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .WithPreviewResponse() - .Build(); - - // Set corrupted fingerprint data - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "corrupted_timestamp"); - SetInternalProperty(config, "PreviewResponseFingerprintReleaseId", "wrong_release"); - SetInternalProperty(config, "PreviewResponseFingerprintLivePreview", "invalid_hash"); - - // Act & Assert - Should handle gracefully and return false - var exception = Record.Exception(() => - { - var result = config.IsCachedPreviewForCurrentQuery(); - Assert.False(result); // Corrupted data should result in cache miss - }); - - Assert.Null(exception); - } - - #endregion - - #region Network and Timeout Errors - - [Fact] - public async Task LivePreviewQueryAsync_NetworkTimeout_HandlesGracefully() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler().ThrowTimeout(); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery( - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "timeout_test_release" - ); - - // Act - method should complete without throwing exception - await client.LivePreviewQueryAsync(query); - - // Assert - timeout is handled gracefully, preview response is not set - var config = client.GetLivePreviewConfig(); - Assert.Null(config.PreviewResponse); // Prefetch failed, no preview response set - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); // Basic config still set - Assert.Equal("timeout_test_release", config.ReleaseId); - } - - [Fact] - public async Task LivePreviewQueryAsync_WebException_HandlesGracefully() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler() - .ThrowWebException("Network connection failed"); - client.Plugins.Add(mockHandler); - - var query = CreateLivePreviewQuery( - previewTimestamp: "2024-11-29T14:30:00.000Z", - releaseId: "network_error_release" - ); - - // Act - method should complete without throwing exception - await client.LivePreviewQueryAsync(query); - - // Assert - network error is handled gracefully, preview response is not set - var config = client.GetLivePreviewConfig(); - Assert.Null(config.PreviewResponse); // Prefetch failed, no preview response set - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); // Basic config still set - Assert.Equal("network_error_release", config.ReleaseId); - } - - [Fact] - public async Task EntryFetch_NetworkError_FallsBackGracefully() - { - // Arrange - var client = CreateClientWithLivePreview(); - var config = client.GetLivePreviewConfig(); - - // Set up cache miss scenario (force network request) - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - SetInternalProperty(config, "ContentTypeUID", "error_ct"); - SetInternalProperty(config, "EntryUID", "error_entry"); - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", "different_timestamp"); - - var mockHandler = new TimelineMockHttpHandler() - .ThrowWebException("Entry fetch failed"); - client.Plugins.Add(mockHandler); - - var contentType = client.ContentType("error_ct"); - var entry = contentType.Entry("error_entry"); - - // Act & Assert - var exception = await Record.ExceptionAsync(async () => - { - await entry.Fetch(); - }); - - // Network error should propagate but not corrupt client state - Assert.NotNull(exception); - - // Client should remain in consistent state - Assert.Equal("2024-11-29T14:30:00.000Z", config.PreviewTimestamp); - } - - #endregion - - #region Concurrency and Threading Errors - - [Fact] - public async Task ConcurrentOperations_ExceptionInOneThread_DoesNotCorruptOthers() - { - // Arrange - var parentClient = CreateClientWithLivePreview(); - var numberOfThreads = 5; - var operationsPerThread = 10; - var exceptionThreadId = 2; // Thread that will throw exceptions - - var results = new ConcurrentBag(); - - // Act - Concurrent operations with exceptions in one thread - var tasks = new Task[numberOfThreads]; - for (int threadId = 0; threadId < numberOfThreads; threadId++) - { - int currentThreadId = threadId; - tasks[threadId] = Task.Run(async () => - { - try - { - for (int i = 0; i < operationsPerThread; i++) - { - var fork = parentClient.Fork(); - - if (currentThreadId == exceptionThreadId && i >= 5) - { - // Simulate error conditions - throw new InvalidOperationException($"Simulated error in thread {currentThreadId}"); - } - - fork.GetLivePreviewConfig().PreviewTimestamp = $"2024-11-29T{currentThreadId:D2}:{i:D2}:00.000Z"; - fork.GetLivePreviewConfig().ReleaseId = $"thread_{currentThreadId}_op_{i}"; - - // Perform timeline operations - var query = CreateLivePreviewQuery( - previewTimestamp: fork.GetLivePreviewConfig().PreviewTimestamp, - releaseId: fork.GetLivePreviewConfig().ReleaseId - ); - - await fork.LivePreviewQueryAsync(query); - - results.Add(new OperationResult - { - ThreadId = currentThreadId, - OperationId = i, - Success = true, - Timestamp = fork.GetLivePreviewConfig().PreviewTimestamp - }); - } - } - catch (InvalidOperationException ex) - { - results.Add(new OperationResult - { - ThreadId = currentThreadId, - OperationId = -1, - Success = false, - Error = ex.Message - }); - } - }); - } - - await Task.WhenAll(tasks); - - // Assert - var allResults = results.ToList(); - var successfulResults = allResults.Where(r => r.Success).ToList(); - var failedResults = allResults.Where(r => !r.Success).ToList(); - - // Successful threads should complete all operations - var successfulThreads = successfulResults.GroupBy(r => r.ThreadId) - .Where(g => g.Key != exceptionThreadId).ToList(); - - foreach (var threadGroup in successfulThreads) - { - Assert.Equal(operationsPerThread, threadGroup.Count()); - } - - // Exception thread should have some failed operations - Assert.True(failedResults.Any(r => r.ThreadId == exceptionThreadId)); - - // Parent client should remain unaffected - Assert.Null(parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Null(parentClient.GetLivePreviewConfig().ReleaseId); - } - - [Fact] - public void ParallelForkCreation_ExceptionDuringFork_DoesNotCorruptParent() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var numberOfForks = 100; - var corruptionIndex = 50; // Fork that will be corrupted - - var results = new ConcurrentBag(); - - // Act - Parallel fork creation with corruption - Parallel.For(0, numberOfForks, i => - { - try - { - var fork = parentClient.Fork(); - - if (i == corruptionIndex) - { - // Simulate corruption of one fork - SetInternalProperty(fork, "LivePreviewConfig", null); - throw new InvalidOperationException("Fork corruption simulation"); - } - - fork.GetLivePreviewConfig().PreviewTimestamp = $"2024-11-29T{i % 24:D2}:00:00.000Z"; - - results.Add(new ForkResult - { - ForkIndex = i, - Success = true, - Timestamp = fork.GetLivePreviewConfig().PreviewTimestamp - }); - } - catch (Exception ex) - { - results.Add(new ForkResult - { - ForkIndex = i, - Success = false, - Error = ex.Message - }); - } - }); - - // Assert - var allResults = results.ToList(); - var successfulForks = allResults.Where(r => r.Success).ToList(); - var failedForks = allResults.Where(r => !r.Success).ToList(); - - // Should have exactly one failure (the corrupted fork) - Assert.Single(failedForks); - Assert.Equal(corruptionIndex, failedForks[0].ForkIndex); - - // All other forks should succeed - Assert.Equal(numberOfForks - 1, successfulForks.Count); - - // Parent client should remain unaffected - Assert.Equal("2024-11-29T14:30:00.000Z", parentClient.GetLivePreviewConfig().PreviewTimestamp); - Assert.Equal("test_release_123", parentClient.GetLivePreviewConfig().ReleaseId); - } - - #endregion - - #region Malformed Data Errors - - [Fact] - public void IsCachedPreviewForCurrentQuery_MalformedTimestamp_HandlesGracefully() - { - // Arrange - var malformedTimestamps = new[] - { - "not-a-timestamp", - "2024-13-50T25:99:99.999Z", // Invalid date/time - "2024/11/29 14:30:00", // Wrong format - "2024-11-29", // Incomplete - "", // Empty string - " ", // Whitespace only - "null", // String "null" - "undefined" // String "undefined" - }; - - foreach (var malformedTimestamp in malformedTimestamps) - { - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp(malformedTimestamp) - .WithPreviewResponse() - .Build(); - - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", malformedTimestamp); - - // Act & Assert - Should not throw - var exception = Record.Exception(() => - { - var result = config.IsCachedPreviewForCurrentQuery(); - // Result can be true or false, but shouldn't throw - }); - - Assert.Null(exception); - } - } - - [Fact] - public void Timeline_MalformedReleaseId_HandlesGracefully() - { - // Arrange - var malformedReleaseIds = new[] - { - "release\nwith\nnewlines", - "release\twith\ttabs", - "release with spaces and special chars !@#$%^&*()", - new string('x', 10000), // Very long string - "\0\0\0", // Null characters - "🚀🎉💯", // Emojis - "" // Potential XSS - }; - - foreach (var malformedReleaseId in malformedReleaseIds) - { - var client = CreateClientWithTimeline(); - - // Act & Assert - Should not throw - var exception = Record.Exception(() => - { - client.GetLivePreviewConfig().ReleaseId = malformedReleaseId; - client.ResetLivePreview(); - }); - - Assert.Null(exception); - } - } - - [Fact] - public async Task LivePreviewQuery_MalformedQueryParameters_HandlesGracefully() - { - // Arrange - var client = CreateClientWithLivePreview(); - var mockHandler = new TimelineMockHttpHandler() - .ForSuccessfulLivePreview(); - client.Plugins.Add(mockHandler); - - var malformedQueries = new[] - { - // Null values - new Dictionary { ["content_type_uid"] = null, ["entry_uid"] = "test" }, - - // Empty values - new Dictionary { ["content_type_uid"] = "", ["entry_uid"] = "" }, - - // Special characters - new Dictionary - { - ["content_type_uid"] = "ct\nwith\nnewlines", - ["entry_uid"] = "entry\twith\ttabs", - ["preview_timestamp"] = "2024-11-29T14:30:00.000Z\0null" - }, - - // Very long values - new Dictionary - { - ["content_type_uid"] = new string('x', 5000), - ["entry_uid"] = new string('y', 5000) - } - }; - - foreach (var malformedQuery in malformedQueries) - { - // Act & Assert - Should handle gracefully - var exception = await Record.ExceptionAsync(async () => - { - await client.LivePreviewQueryAsync(malformedQuery); - }); - - // May throw validation errors, but should not crash or corrupt state - if (exception != null) - { - Assert.IsNotType(exception); - Assert.IsNotType(exception); - } - } - } - - #endregion - - #region Memory and Resource Errors - - [Fact] - public void Timeline_LargeResponseObjects_HandlesMemoryPressure() - { - // Arrange - var config = TimelineTestDataBuilder.New() - .WithPreviewTimestamp("2024-11-29T14:30:00.000Z") - .Build(); - - // Create extremely large response to test memory handling - var largeArray = new JArray(); - for (int i = 0; i < 10000; i++) - { - largeArray.Add(JObject.Parse($@"{{ - ""id"": {i}, - ""data"": ""{new string('x', 100)}"" - }}")); - } - - var largeResponse = new JObject - { - ["entry"] = new JObject - { - ["uid"] = "large_entry", - ["massive_array"] = largeArray, - ["large_string"] = new string('y', 50000) - } - }; - - // Act & Assert - Should handle large objects without crashing - var exception = Record.Exception(() => - { - config.PreviewResponse = largeResponse; - SetInternalProperty(config, "PreviewResponseFingerprintPreviewTimestamp", config.PreviewTimestamp); - - // Test cache operations with large response - var isCached = config.IsCachedPreviewForCurrentQuery(); - Assert.True(isCached); - }); - - Assert.Null(exception); - } - - [Fact] - public void Fork_MemoryPressure_HandlesGracefully() - { - // Arrange - var parentClient = CreateClientWithTimeline(); - var numberOfForks = 1000; - - // Act & Assert - Should handle many forks without memory issues - var exception = Record.Exception(() => - { - var forks = new ContentstackClient[numberOfForks]; - - for (int i = 0; i < numberOfForks; i++) - { - forks[i] = parentClient.Fork(); - forks[i].GetLivePreviewConfig().PreviewTimestamp = $"2024-11-{(i % 12) + 1:D2}-01T00:00:00.000Z"; - - // Occasionally force garbage collection to test memory pressure - if (i % 100 == 0) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - - // Verify forks are still functional - for (int i = 0; i < Math.Min(10, numberOfForks); i++) - { - Assert.NotNull(forks[i].GetLivePreviewConfig()); - } - }); - - Assert.Null(exception); - } - - #endregion - - #region Cleanup and Disposal Errors - - [Fact] - public void ResetLivePreview_AfterObjectDisposal_HandlesGracefully() - { - // Arrange - var client = CreateClientWithTimeline(); - var config = client.GetLivePreviewConfig(); - - // Set up timeline state - config.PreviewResponse = CreateMockPreviewResponse(); - config.PreviewTimestamp = "2024-11-29T14:30:00.000Z"; - - // Simulate object disposal/corruption - config.PreviewResponse = null; - SetInternalProperty(config, "ContentTypeUID", null); - SetInternalProperty(config, "EntryUID", null); - - // Act & Assert - Should handle cleanup gracefully - var exception = Record.Exception(() => - { - client.ResetLivePreview(); - - // Multiple resets should be safe - client.ResetLivePreview(); - client.ResetLivePreview(); - }); - - Assert.Null(exception); - } - - #endregion - - #region Helper Classes - - public class OperationResult - { - public int ThreadId { get; set; } - public int OperationId { get; set; } - public bool Success { get; set; } - public string Timestamp { get; set; } - public string Error { get; set; } - } - - public class ForkResult - { - public int ForkIndex { get; set; } - public bool Success { get; set; } - public string Timestamp { get; set; } - public string Error { get; set; } - } - - public class ConcurrentBag : List - { - private readonly object _lock = new object(); - - public new void Add(T item) - { - lock (_lock) - { - base.Add(item); - } - } - - public new List ToList() - { - lock (_lock) - { - return new List(this); - } - } - } - - #endregion - } -} \ No newline at end of file diff --git a/Contentstack.Core/Configuration/LivePreviewConfig.cs b/Contentstack.Core/Configuration/LivePreviewConfig.cs index 07c5310d..92d6c827 100644 --- a/Contentstack.Core/Configuration/LivePreviewConfig.cs +++ b/Contentstack.Core/Configuration/LivePreviewConfig.cs @@ -1,5 +1,4 @@ -using System; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace Contentstack.Core.Configuration { @@ -12,25 +11,8 @@ public class LivePreviewConfig internal string LivePreview { get; set; } internal string ContentTypeUID { get; set; } internal string EntryUID { get; set; } - internal JObject PreviewResponse { get; set; } - - /// - /// Snapshot of preview_timestamp / release_id / live_preview when was set (prefetch). - /// Prevents Entry.Fetch from short-circuiting with a draft from a previous Live Preview query. - /// - internal string PreviewResponseFingerprintPreviewTimestamp { get; set; } - internal string PreviewResponseFingerprintReleaseId { get; set; } - internal string PreviewResponseFingerprintLivePreview { get; set; } - + internal JsonObject PreviewResponse { get; set; } public string ReleaseId {get; set;} public string PreviewTimestamp {get; set;} - - internal bool IsCachedPreviewForCurrentQuery() - { - if (PreviewResponse == null) return false; - return string.Equals(PreviewTimestamp, PreviewResponseFingerprintPreviewTimestamp, StringComparison.Ordinal) - && string.Equals(ReleaseId, PreviewResponseFingerprintReleaseId, StringComparison.Ordinal) - && string.Equals(LivePreview, PreviewResponseFingerprintLivePreview, StringComparison.Ordinal); - } } } diff --git a/Contentstack.Core/ContentstackClient.cs b/Contentstack.Core/ContentstackClient.cs index 5b5eeb4a..caf7acbb 100644 --- a/Contentstack.Core/ContentstackClient.cs +++ b/Contentstack.Core/ContentstackClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Contentstack.Core.Internals; using Contentstack.Core.Configuration; @@ -61,100 +61,6 @@ private string _Url private string currentContenttypeUid = null; private string currentEntryUid = null; public List Plugins { get; set; } = new List(); - - private static LivePreviewConfig CloneLivePreviewConfig(LivePreviewConfig source) - { - if (source == null) return null; - - return new LivePreviewConfig - { - Enable = source.Enable, - Host = source.Host, - ManagementToken = source.ManagementToken, - PreviewToken = source.PreviewToken, - ReleaseId = source.ReleaseId, - PreviewTimestamp = source.PreviewTimestamp, - - // internal state (same assembly) - LivePreview = source.LivePreview, - ContentTypeUID = source.ContentTypeUID, - EntryUID = source.EntryUID, - PreviewResponse = source.PreviewResponse, - PreviewResponseFingerprintPreviewTimestamp = source.PreviewResponseFingerprintPreviewTimestamp, - PreviewResponseFingerprintReleaseId = source.PreviewResponseFingerprintReleaseId, - PreviewResponseFingerprintLivePreview = source.PreviewResponseFingerprintLivePreview - }; - } - - private static ContentstackOptions CloneOptions(ContentstackOptions source) - { - if (source == null) return null; - - return new ContentstackOptions - { - ApiKey = source.ApiKey, - AccessToken = source.AccessToken, - DeliveryToken = source.DeliveryToken, - Environment = source.Environment, - Host = source.Host, - Proxy = source.Proxy, - Region = source.Region, - Version = source.Version, - Branch = source.Branch, - Timeout = source.Timeout, - EarlyAccessHeader = source.EarlyAccessHeader, - LivePreview = CloneLivePreviewConfig(source.LivePreview) - }; - } - - /// - /// Clears any in-memory Live Preview context (hash, release, timestamp, content type, entry). - /// Useful when switching back to the Delivery API after using Live Preview / Timeline preview. - /// - public void ResetLivePreview() - { - if (this.LivePreviewConfig == null) return; - - this.LivePreviewConfig.LivePreview = null; - this.LivePreviewConfig.ReleaseId = null; - this.LivePreviewConfig.PreviewTimestamp = null; - this.LivePreviewConfig.ContentTypeUID = null; - this.LivePreviewConfig.EntryUID = null; - this.LivePreviewConfig.PreviewResponse = null; - this.LivePreviewConfig.PreviewResponseFingerprintPreviewTimestamp = null; - this.LivePreviewConfig.PreviewResponseFingerprintReleaseId = null; - this.LivePreviewConfig.PreviewResponseFingerprintLivePreview = null; - } - - /// - /// Creates a new client instance with the same configuration but isolated in-memory state. - /// Use this to safely perform Timeline comparisons (left/right) without shared Live Preview context. - /// - public ContentstackClient Fork() - { - var forked = new ContentstackClient(CloneOptions(_options)); - - // Clone current LivePreviewConfig state (not from original options) - if (this.LivePreviewConfig != null) - { - forked.LivePreviewConfig = CloneLivePreviewConfig(this.LivePreviewConfig); - } - - // Preserve any runtime header mutations (e.g., custom headers added via SetHeader). - if (this._LocalHeaders != null) - { - foreach (var kvp in this._LocalHeaders) - { - forked.SetHeader(kvp.Key, kvp.Value?.ToString()); - } - } - - // Carry over current content type / entry hints (used when live preview query omits them) - forked.currentContenttypeUid = this.currentContenttypeUid; - forked.currentEntryUid = this.currentEntryUid; - - return forked; - } /// /// Initializes a instance of the class. /// @@ -205,7 +111,7 @@ public ContentstackClient(IOptions options) this.SetConfig(cnfig); if (_options.LivePreview != null) { - this.LivePreviewConfig = CloneLivePreviewConfig(_options.LivePreview); + this.LivePreviewConfig = _options.LivePreview; } else { @@ -431,12 +337,7 @@ public async Task GetContentTypes(Dictionary param = null } } - /// - /// Fetches draft entry JSON from the Live Preview host (Java Stack.livePreviewQuery equivalent). - /// Always uses the configured preview host so the call succeeds even when the delivery base URL - /// would still point at CDN (e.g. live_preview hash is "init"). - /// - private async Task GetLivePreviewData() + private async Task GetLivePreviewData() { Dictionary headerAll = new Dictionary(); @@ -481,25 +382,11 @@ private async Task GetLivePreviewData() try { HttpRequestHandler RequestHandler = new HttpRequestHandler(this); - string basePreview = this.Config.getLivePreviewUrl(this.LivePreviewConfig); - string URL = String.Format("{0}/content_types/{1}/entries/{2}", basePreview, this.LivePreviewConfig.ContentTypeUID, this.LivePreviewConfig.EntryUID); + //string branch = this.Config.Branch ? this.Config.Branch : "main"; + string URL = String.Format("{0}/content_types/{1}/entries/{2}", this.Config.getBaseUrl(this.LivePreviewConfig, this.LivePreviewConfig.ContentTypeUID), this.LivePreviewConfig.ContentTypeUID, this.LivePreviewConfig.EntryUID); var outputResult = await RequestHandler.ProcessRequest(URL, headerAll, mainJson, Branch: this.Config.Branch, isLivePreview: true, timeout: this.Config.Timeout, proxy: this.Config.Proxy); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - if (data == null) return null; - if (data["entry"] is JObject single && single.HasValues) - return single; - if (data["entries"] is JArray arr && arr.Count > 0) - { - string targetUid = this.LivePreviewConfig.EntryUID; - foreach (var token in arr) - { - if (token is JObject jo && jo["uid"] != null - && string.Equals(jo["uid"].ToString(), targetUid, StringComparison.Ordinal)) - return jo; - } - return arr[0] as JObject; - } - return null; + JsonObject data = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); + return data["entry"]!.AsObject(); } catch (Exception ex) { @@ -721,10 +608,6 @@ public async Task LivePreviewQueryAsync(Dictionary query) this.LivePreviewConfig.LivePreview = null; this.LivePreviewConfig.PreviewTimestamp = null; this.LivePreviewConfig.ReleaseId = null; - this.LivePreviewConfig.PreviewResponse = null; - this.LivePreviewConfig.PreviewResponseFingerprintPreviewTimestamp = null; - this.LivePreviewConfig.PreviewResponseFingerprintReleaseId = null; - this.LivePreviewConfig.PreviewResponseFingerprintLivePreview = null; if (query.Keys.Contains("content_type_uid")) { string contentTypeUID = null; @@ -768,28 +651,10 @@ public async Task LivePreviewQueryAsync(Dictionary query) query.TryGetValue("preview_timestamp", out PreviewTimestamp); this.LivePreviewConfig.PreviewTimestamp = PreviewTimestamp; } - - if (this.LivePreviewConfig.Enable - && !string.IsNullOrEmpty(this.LivePreviewConfig.Host) - && !string.IsNullOrEmpty(this.LivePreviewConfig.ContentTypeUID) - && !string.IsNullOrEmpty(this.LivePreviewConfig.EntryUID)) - { - try - { - var draft = await GetLivePreviewData(); - if (draft != null && draft.Type == JTokenType.Object && draft.HasValues) - { - this.LivePreviewConfig.PreviewResponse = draft; - this.LivePreviewConfig.PreviewResponseFingerprintPreviewTimestamp = this.LivePreviewConfig.PreviewTimestamp; - this.LivePreviewConfig.PreviewResponseFingerprintReleaseId = this.LivePreviewConfig.ReleaseId; - this.LivePreviewConfig.PreviewResponseFingerprintLivePreview = this.LivePreviewConfig.LivePreview; - } - } - catch - { - // Prefetch failed: Entry.Fetch still uses preview headers on the network path. - } - } + //if (!string.IsNullOrEmpty(this.LivePreviewConfig.LivePreview)) + //{ + // this.LivePreviewConfig.PreviewResponse = await GetLivePreviewData(); + //} } /// diff --git a/Contentstack.Core/Models/Entry.cs b/Contentstack.Core/Models/Entry.cs index c0c89b32..dc0af276 100644 --- a/Contentstack.Core/Models/Entry.cs +++ b/Contentstack.Core/Models/Entry.cs @@ -1390,47 +1390,13 @@ public async Task Fetch() //Dictionary urlQueries = new Dictionary(); - var livePreviewConfig = this.ContentTypeInstance?.StackInstance?.LivePreviewConfig; - if (livePreviewConfig != null - && livePreviewConfig.Enable - && livePreviewConfig.PreviewResponse != null - && livePreviewConfig.PreviewResponse.Type == JTokenType.Object - && livePreviewConfig.PreviewResponse.HasValues - && !string.IsNullOrEmpty(this.Uid) - && string.Equals(livePreviewConfig.EntryUID, this.Uid, StringComparison.Ordinal) - && this.ContentTypeInstance != null - && string.Equals( - livePreviewConfig.ContentTypeUID, - this.ContentTypeInstance.ContentTypeId, - StringComparison.OrdinalIgnoreCase) - && livePreviewConfig.IsCachedPreviewForCurrentQuery()) - { - try - { - var serializedFromPreview = livePreviewConfig.PreviewResponse.ToObject( - this.ContentTypeInstance.StackInstance.Serializer); - if (serializedFromPreview != null && serializedFromPreview.GetType() == typeof(Entry)) - { - (serializedFromPreview as Entry).ContentTypeInstance = this.ContentTypeInstance; - } - return serializedFromPreview; - } - catch - { - // Fall through to network fetch. - } - } - if (headers != null && headers.Count() > 0) { foreach (var header in headers) { - if (this.ContentTypeInstance != null - && livePreviewConfig != null - && livePreviewConfig.Enable - && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId - && header.Key == "access_token" - && !string.IsNullOrEmpty(livePreviewConfig.LivePreview)) + if (this.ContentTypeInstance.StackInstance.LivePreviewConfig.Enable == true + && this.ContentTypeInstance.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId + && header.Key == "access_token" && !string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)) { continue; } @@ -1438,36 +1404,25 @@ public async Task Fetch() } } bool isLivePreview = false; - var hasLivePreviewContext = - this.ContentTypeInstance != null - && livePreviewConfig != null - && livePreviewConfig.Enable - && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId - && ( - !string.IsNullOrEmpty(livePreviewConfig.LivePreview) - || !string.IsNullOrEmpty(livePreviewConfig.ReleaseId) - || !string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp) - ); - - if (hasLivePreviewContext) + if (this.ContentTypeInstance.StackInstance.LivePreviewConfig.Enable == true && this.ContentTypeInstance.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId) { - mainJson.Add("live_preview", string.IsNullOrEmpty(livePreviewConfig.LivePreview)? "init" : livePreviewConfig.LivePreview); + mainJson.Add("live_preview", string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)? "init" : this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview); - if (!string.IsNullOrEmpty(livePreviewConfig.ManagementToken)) { - headerAll["authorization"] = livePreviewConfig.ManagementToken; - } else if (!string.IsNullOrEmpty(livePreviewConfig.PreviewToken)) { - headerAll["preview_token"] = livePreviewConfig.PreviewToken; + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ManagementToken)) { + headerAll["authorization"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ManagementToken; + } else if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewToken)) { + headerAll["preview_token"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewToken; } else { throw new LivePreviewException(); } - if (!string.IsNullOrEmpty(livePreviewConfig.ReleaseId)) + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId)) { - headerAll["release_id"] = livePreviewConfig.ReleaseId; + headerAll["release_id"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId; } - if (!string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp)) + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp)) { - headerAll["preview_timestamp"] = livePreviewConfig.PreviewTimestamp; + headerAll["preview_timestamp"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp; } isLivePreview = true; diff --git a/Contentstack.Core/Models/Query.cs b/Contentstack.Core/Models/Query.cs index b82e0ae9..7e2608d2 100644 --- a/Contentstack.Core/Models/Query.cs +++ b/Contentstack.Core/Models/Query.cs @@ -1883,37 +1883,26 @@ private async Task Exec() Dictionary mainJson = new Dictionary(); bool isLivePreview = false; - var livePreviewConfig = this.ContentTypeInstance?.StackInstance?.LivePreviewConfig; - var hasLivePreviewContext = - this.ContentTypeInstance != null - && livePreviewConfig != null - && livePreviewConfig.Enable - && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId - && ( - !string.IsNullOrEmpty(livePreviewConfig.LivePreview) - || !string.IsNullOrEmpty(livePreviewConfig.ReleaseId) - || !string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp) - ); - - if (hasLivePreviewContext) - { - mainJson.Add("live_preview", string.IsNullOrEmpty(livePreviewConfig.LivePreview) ? "init" : livePreviewConfig.LivePreview); - - if (!string.IsNullOrEmpty(livePreviewConfig.ManagementToken)) { - headerAll["authorization"] = livePreviewConfig.ManagementToken; - } else if (!string.IsNullOrEmpty(livePreviewConfig.PreviewToken)) { - headerAll["preview_token"] = livePreviewConfig.PreviewToken; + if (this.ContentTypeInstance!=null && this.ContentTypeInstance.StackInstance.LivePreviewConfig.Enable == true + && this.ContentTypeInstance.StackInstance?.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId) + { + mainJson.Add("live_preview", string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview) ? "init" : this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview); + + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ManagementToken)) { + headerAll["authorization"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ManagementToken; + } else if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewToken)) { + headerAll["preview_token"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewToken; } else { throw new LivePreviewException(); } - if (!string.IsNullOrEmpty(livePreviewConfig.ReleaseId)) + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId)) { - headerAll["release_id"] = livePreviewConfig.ReleaseId; + headerAll["release_id"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId; } - if (!string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp)) + if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp)) { - headerAll["preview_timestamp"] = livePreviewConfig.PreviewTimestamp; + headerAll["preview_timestamp"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp; } isLivePreview = true; @@ -1923,11 +1912,10 @@ private async Task Exec() { foreach (var header in headers) { - if (this.ContentTypeInstance!=null && livePreviewConfig != null - && livePreviewConfig.Enable - && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance?.ContentTypeId + if (this.ContentTypeInstance!=null && this.ContentTypeInstance?.StackInstance.LivePreviewConfig.Enable == true + && this.ContentTypeInstance?.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance?.ContentTypeId && header.Key == "access_token" - && !string.IsNullOrEmpty(livePreviewConfig.LivePreview)) + && !string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)) { continue; } diff --git a/Directory.Build.props b/Directory.Build.props index a96a399e..1f737f6c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 2.27.0 + 3.0.0 - \ No newline at end of file + diff --git a/README.md b/README.md index a07ff77c..ea063835 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ .NET SDK for Contentstack's Content Delivery API -Contributor and agent conventions: see **[AGENTS.md](AGENTS.md)**. - ## Getting Started This guide will help you get started with our .NET SDK to build apps powered by Contentstack. diff --git a/skills/README.md b/skills/README.md deleted file mode 100644 index f3e91cdd..00000000 --- a/skills/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Skills – Contentstack .NET SDK - -Source of truth for detailed guidance. Read **[AGENTS.md](../AGENTS.md)** first, then open the skill that matches your task. - -## When to use which skill - -| Skill folder | Use when | -|--------------|----------| -| `dev-workflow` | Building the solution, versioning, CI workflows, onboarding, PR prep. | -| `sdk-core-patterns` | Architecture, `ContentstackClient`, HTTP layer, DI, plugins, request flow. | -| `query-building` | Query operators, fluent API, pagination, sync, taxonomy, `Query.cs`. | -| `models-and-serialization` | Entry/Asset models, JSON converters, `ContentstackCollection`, serialization. | -| `error-handling` | Exceptions, `ErrorMessages`, parsing API errors. | -| `testing` | Writing or debugging unit/integration tests, coverage, test layout. | -| `code-review` | Reviewing a PR against SDK-specific checklist. | - -Each folder contains **`SKILL.md`** with YAML frontmatter (`name`, `description`) for agent discovery. - -### Cursor - -In Cursor, you can also reference a skill in chat with `@skills/` (for example `@skills/testing`). diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md deleted file mode 100644 index 9ea79041..00000000 --- a/skills/code-review/SKILL.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -name: code-review -description: SDK-specific PR review checklist for the Contentstack .NET SDK — covers breaking changes, HTTP layer correctness, exception handling, serialization, fluent API patterns, configuration, test coverage, multi-targeting, plugin lifecycle, and internal visibility. Use when reviewing pull requests, examining code changes, or performing code quality assessments on this SDK. ---- - -# Code Review - -## When to use - -- Reviewing a PR or diff against SDK conventions. -- Self-review before opening a PR. - -## Severity levels - -- **Critical** — Must fix before merge (correctness, breaking changes, security) -- **Important** — Should fix (maintainability, SDK patterns, consistency) -- **Suggestion** — Consider improving (style, optimization) - -The sections below provide category-by-category checklists (breaking changes, HTTP, exceptions, serialization, fluent API, config, tests, multi-targeting, plugins, visibility) and a short red-flag list for quick scanning. - - -## Code Review Checklist - -### Breaking Changes Checklist - -```markdown -## Breaking Changes Review -- [ ] No public method signatures removed or changed without [Obsolete] deprecation -- [ ] No [JsonProperty] values changed (would break consumer deserialization silently) -- [ ] No ContentstackOptions public property removed -- [ ] New required options have defaults (don't break existing consumers who don't set them) -- [ ] No namespace renames without backward-compatible type aliases -- [ ] No IContentstackPlugin interface signature changed -- [ ] Version bump planned if breaking change is intentional (Directory.Build.props) -``` - -### HTTP Layer Checklist - -```markdown -## HTTP Layer Review -- [ ] All HTTP calls route through HttpRequestHandler.ProcessRequest -- [ ] No HttpClient instantiation anywhere in the PR -- [ ] New query params added to UrlQueries dict (not directly to URL string) -- [ ] New field-level filters added to QueryValueJson dict -- [ ] New headers added via Headers parameter to ProcessRequest -- [ ] Branch header uses "branch" key, passed as separate Branch parameter -- [ ] No hardcoded URLs — BaseUrl comes from Config.BaseUrl -- [ ] Live preview URL resolved via Config.getBaseUrl() — not hardcoded -- [ ] ProcessRequest result (string JSON) parsed, not further HTTP calls made -``` - -### Exception Handling Checklist - -```markdown -## Exception Handling Review -- [ ] Domain-specific exception type used (QueryFilterException, AssetException, etc.) -- [ ] No bare `throw new Exception(...)` or `throw new ContentstackException(...)` -- [ ] All message strings sourced from ErrorMessages.cs constants -- [ ] No string literals in throw statements -- [ ] GetContentstackError(ex) called when catching WebException from HTTP calls -- [ ] ErrorCode, StatusCode, Errors preserved when re-wrapping exceptions -- [ ] New domain area has new exception class with factory methods -- [ ] New error messages added to correct section in ErrorMessages.cs -- [ ] FormatExceptionDetails(innerEx) used in ProcessingError factory methods -``` - -### Serialization Checklist - -```markdown -## Serialization Review -- [ ] All public properties mapping CDA JSON fields have [JsonProperty("snake_case")] -- [ ] No reliance on default Newtonsoft.Json camelCase or PascalCase matching -- [ ] Custom deserialization uses [CSJsonConverter] + JsonConverter subclass -- [ ] JsonConverter placed in Contentstack.Core/Internals/ (internal class) -- [ ] No System.Text.Json usage -- [ ] No JsonConvert.DeserializeObject with hardcoded type outside of converter -- [ ] ContentstackCollection used for list responses (not List directly) -- [ ] "entries" token used for entry collection, "assets" for asset collection -``` - -### Fluent API Checklist - -```markdown -## Fluent API Review -- [ ] Every Query filter/operator method returns `return this;` -- [ ] Null key validated at start of method → QueryFilterException.Create() -- [ ] Empty string key validated → QueryFilterException.Create() -- [ ] Operator value stored in QueryValueJson[key][$operator] nested dict -- [ ] URL-level params stored in UrlQueries[key] -- [ ] Method name follows verb+noun pattern (GreaterThan, ContainedIn, NotExists) -- [ ] No mutation of QueryValueJson or UrlQueries outside of the Query class itself -- [ ] And()/Or() accept Query[] (not raw dictionaries) -``` - -### Configuration Checklist - -```markdown -## Configuration Review -- [ ] New options added to ContentstackOptions (public class), not Config (internal) -- [ ] New property has XML doc comment -- [ ] Default value set in ContentstackOptions() constructor or property initializer -- [ ] ContentstackClient constructor reads new option and passes to Config -- [ ] Config never exposed as public property -- [ ] New option tested in ContentstackOptionsUnitTests.cs -- [ ] ASP.NET Core binding works (IOptions path verified) -``` - -### Test Coverage Checklist - -```markdown -## Test Coverage Review -- [ ] Unit test for each new public Query method (QueryValueJson assertion via reflection) -- [ ] Unit test for null key input → QueryFilterException -- [ ] Unit test for empty key input → QueryFilterException -- [ ] Unit test for fluent return (Assert.Equal(query, result)) -- [ ] Integration test file in Integration/{FeatureName}Tests/ subfolder -- [ ] Integration test class extends IntegrationTestBase -- [ ] Integration test constructor takes ITestOutputHelper output -- [ ] CreateClient() used (not manual ContentstackClient construction) -- [ ] LogArrange/LogAct/LogAssert used in correct order -- [ ] TestAssert.* used (not raw Assert.*) -- [ ] [Fact(DisplayName = "FeatureArea - Component Description")] present -- [ ] Happy path test (valid params → expected response) -- [ ] Error path test (invalid params or not found → expected exception) -``` - -### Multi-Targeting Checklist - -```markdown -## Multi-Targeting Review -- [ ] No HttpClient (netstandard2.0 HttpClient has behavioural differences from net4x) -- [ ] No System.Text.Json (not available without separate package in netstandard2.0) -- [ ] No record types (C# 9, requires LangVersion setting for net47/net472) -- [ ] No default interface implementations (C# 8, may affect net47) -- [ ] No nullable reference types without #nullable enable guard -- [ ] No top-level statements (not applicable to library projects but worth checking) -- [ ] Tested compile against netstandard2.0 target (or verified via CI) -``` - -### Plugin Lifecycle Checklist - -```markdown -## Plugin Lifecycle Review -- [ ] New feature that makes HTTP calls uses HttpRequestHandler (plugins run automatically) -- [ ] No WebRequest.Create() called directly in new model classes -- [ ] IContentstackPlugin interface not modified (breaking for all plugin consumers) -- [ ] RequestLoggingPlugin still works with any new request/response changes -- [ ] Plugin.OnRequest receives HttpWebRequest before send -- [ ] Plugin.OnResponse receives response string (can mutate/inspect) -``` - -### Internal Visibility Checklist - -```markdown -## Internal Visibility Review -- [ ] New utility/helper classes in Internals/ are marked `internal` -- [ ] New model types intended for consumers are in Models/ and `public` -- [ ] New configuration types are in Configuration/ and `public` -- [ ] No public exposure of Config, HttpRequestHandler, or VersionUtility -- [ ] InternalsVisibleTo not modified (already covers both test projects) -- [ ] New internal methods accessible in unit tests without changes -``` - -### Common Issues Found in Past PRs - -#### Silent Deserialization Failures -`[JsonProperty]` omitted → field is always null at runtime, no exception. Verify all properties that map CDA JSON fields. - -#### Exception Message in Throw -```csharp -// Bad -throw new QueryFilterException("Please provide valid params."); - -// Good -throw QueryFilterException.Create(innerEx); -// or -throw new QueryFilterException(ErrorMessages.QueryFilterError); -``` - -#### Hardcoded Environment -```csharp -// Bad — breaks for consumers with different environments -mainJson["environment"] = "production"; - -// Correct — already done in Exec() -mainJson["environment"] = ContentTypeInstance.StackInstance.Config.Environment; -``` - -#### Returning void from Query Method -```csharp -// Bad — breaks fluent chaining -public void SetMyParam(string value) { UrlQueries["my_param"] = value; } - -// Good -public Query SetMyParam(string value) { UrlQueries["my_param"] = value; return this; } -``` - -#### Dictionary Not Initialized for QueryValueJson Entry -```csharp -// Bad — throws KeyNotFoundException or InvalidCastException -((Dictionary)QueryValueJson[key])["$op"] = value; - -// Good — guard with ContainsKey -if (!QueryValueJson.ContainsKey(key)) - QueryValueJson[key] = new Dictionary(); -((Dictionary)QueryValueJson[key])["$op"] = value; -``` - -## SDK-specific red flags - -Quick scan for anti-patterns in PRs: - -``` -❌ new HttpClient() — use HttpRequestHandler -❌ throw new Exception("message") — use typed ContentstackException subclass -❌ "hardcoded_field_name" — use [JsonProperty] or ErrorMessages constant -❌ public Config GetConfig() — Config is internal by design -❌ return void — Query methods return Query (fluent) -❌ [JsonProperty] omitted — CDA uses snake_case; PascalCase won't deserialize -❌ in .csproj — use Directory.Build.props -``` - -For full category-by-category review, see **Code review checklist** above. diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md deleted file mode 100644 index d0378b04..00000000 --- a/skills/dev-workflow/SKILL.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -name: dev-workflow -description: Local development and CI workflow for the Contentstack .NET SDK — solution layout, dotnet build/test commands, package versioning in Directory.Build.props, GitHub Actions (unit tests, CodeQL, NuGet), and integration test credentials. Use when onboarding, running builds, preparing a PR, or finding where CI is defined. ---- - -# Dev workflow – Contentstack .NET SDK - -## When to use - -- Onboarding, building, or running tests locally. -- Finding which workflow applies to PRs, releases, or security scans. -- Maintainer tasks: DocFX, NuGet publish expectations. - -## Invariants - -- Version in **`Directory.Build.props`** only for package version. -- Integration tests need **`app.config`** (or equivalent) — do not commit secrets. - -## Repo tooling — CI, release, docs, security - -### Local commands - -| Action | Command | -|--------|---------| -| Build | `dotnet build Contentstack.Net.sln` | -| Unit tests | `dotnet test Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj` | -| Integration tests | `dotnet test Contentstack.Core.Tests/Contentstack.Core.Tests.csproj` (credentials) | - -Package version: [`Directory.Build.props`](../../../Directory.Build.props) (``). - -### CI — unit tests - -[`.github/workflows/unit-test.yml`](../../../.github/workflows/unit-test.yml): on `pull_request` and `push`, Windows runner, .NET 7, `dotnet restore` → `dotnet build Contentstack.Net.sln` → `dotnet test` on `Contentstack.Core.Unit.Tests`. - -### CI — branch and release flow - -Release PRs follow the direct flow `development -> master` (or `main` where applicable); `staging` is no longer part of the release path. Keep feature work targeting `development`, and use the back-merge workflow to keep branches aligned after production releases. - -### CI — CodeQL - -[`.github/workflows/codeql-analysis.yml`](../../../.github/workflows/codeql-analysis.yml): static analysis on PRs (language matrix includes C# as configured in the workflow). - -### Release — NuGet - -[`.github/workflows/nuget-publish.yml`](../../../.github/workflows/nuget-publish.yml): triggered on **`release` `created`**. - -- `dotnet pack -c Release -o out` -- Push `contentstack.csharp.*.nupkg` to NuGet.org (`NUGET_API_KEY` / `NUGET_AUTH_TOKEN` secrets — names appear in workflow; values are org secrets). -- Secondary job may push to GitHub Packages (`nuget.pkg.github.com`). - -Maintainers: ensure `Directory.Build.props` version matches the release tag policy before publishing. - -### DocFX API docs - -Configuration: [`docfx_project/docfx.json`](../../../docfx_project/docfx.json). Output site: `_site/` under the docfx project. - -Prerequisite: [DocFX](https://dotnet.github.io/docfx/) CLI installed. - -```bash -cd docfx_project -docfx docfx.json -# or: docfx build docfx.json -``` - -If metadata `src` paths do not resolve (e.g. `src/**.csproj`), adjust `docfx.json` or project layout so metadata generation matches this repository’s structure. - -Related: [`filterRules.yml`](../../../docfx_project/filterRules.yml) for API filter rules. - -### Security / policy automation - -**SCA (dependencies):** [`.github/workflows/sca-scan.yml`](../../../.github/workflows/sca-scan.yml) — on PR, `dotnet restore`, then Snyk Dotnet action against `Contentstack.Core/obj/project.assets.json` (`SNYK_TOKEN` secret). Failures indicate vulnerable packages or scan misconfiguration. - -**Policy (repository hygiene):** [`.github/workflows/policy-scan.yml`](../../../.github/workflows/policy-scan.yml) — for public repos, checks presence of `SECURITY.md` (or `.github/SECURITY.md`) and a license file. Adjust repo contents if these jobs fail. - -When a PR fails these jobs, inspect the workflow log and fix dependencies or policy items as required by your team. - -### Projects (layout) - -| Path | Role | -|------|------| -| `Contentstack.Net.sln` | Main solution | -| `Contentstack.Core/` | Delivery SDK package | -| `Contentstack.AspNetCore/` | DI package | -| `Contentstack.Core.Unit.Tests/` | Unit tests | -| `Contentstack.Core.Tests/` | Integration tests | - -## Related skills - -- [SDK core patterns](../sdk-core-patterns/SKILL.md) — architecture and HTTP. -- [Testing](../testing/SKILL.md) — unit vs integration patterns and credentials. diff --git a/skills/error-handling/SKILL.md b/skills/error-handling/SKILL.md deleted file mode 100644 index b4b08a52..00000000 --- a/skills/error-handling/SKILL.md +++ /dev/null @@ -1,298 +0,0 @@ ---- -name: error-handling -description: Error handling patterns for the Contentstack .NET SDK — ContentstackException hierarchy, domain-specific typed exceptions with static factory methods, GetContentstackError WebException parsing, centralized ErrorMessages.cs strings, and consumer catch order. Use when adding new exception types for a new domain area, modifying error messages, handling API HTTP errors, debugging WebException responses, or catching SDK errors in consumer code. ---- - -# Error Handling - -## When to use - -- New exceptions, new `ErrorMessages` strings, or HTTP error parsing. -- Reviewing catches in SDK internals or consumer apps. - -## Invariants - -- Throw **domain-specific** subclasses of `ContentstackException`, not raw `ContentstackException` for domain cases. -- **All user-facing message strings** live in **`ErrorMessages.cs`** — no inline strings in `throw`. -- On HTTP errors, use **`GetContentstackError`** and preserve **`ErrorCode`**, **`StatusCode`**, **`Errors`** when re-throwing. - -## Exception hierarchy (summary) - -`ContentstackException` → `QueryFilterException`, `AssetException`, `LivePreviewException`, `GlobalFieldException`, `EntryException`, `TaxonomyException`, `ContentTypeException` (see `Contentstack.Core/Internals/ContentstackExceptions.cs`). - -Base properties: `ErrorCode`, `StatusCode`, `Errors`. - -## Error Patterns Reference - -### Complete ErrorMessages Catalogue - -All strings in `Contentstack.Core/Internals/ErrorMessages.cs`: - -#### Query and Filter -```csharp -QueryFilterError = "Please provide valid params." -InvalidParamsError = "Invalid parameters provided. {0}" -``` - -#### Asset -```csharp -AssetJsonConversionError = "Failed to convert asset JSON. Please check the asset format and data integrity." -AssetProcessingError = "An error occurred while processing the asset. {0}" -AssetLibraryRequestError = "Exception in {0}: {1}\nStackTrace: {2}" -``` - -#### Entry -```csharp -EntryProcessingError = "An error occurred while processing the entry. {0}" -EntryUidRequired = "Please set entry uid." -EntryNotFoundInCache = "Entry is not present in cache" -``` - -#### Global Field -```csharp -GlobalFieldIdNullError = "GlobalFieldId required. This value cannot be null or empty, define it in your configuration." -GlobalFieldProcessingError = "An error occurred while processing the globalField. {0}" -GlobalFieldQueryError = "Global field query failed. Check your query syntax and field schema before retrying." -``` - -#### Live Preview -```csharp -LivePreviewTokenMissing = "Live Preview token missing. Add either a PreviewToken or a ManagementToken in the LivePreviewConfig." -``` - -#### Client Request -```csharp -ContentstackClientRequestError = "Contentstack client request failed. Check your network settings or request parameters and try again: {0}" -ContentstackSyncRequestError = "An error occurred while processing the Contentstack client request: {0}" -``` - -#### Taxonomy -```csharp -TaxonomyProcessingError = "An error occurred while processing the taxonomy operation: {0}" -``` - -#### Content Type -```csharp -ContentTypeProcessingError = "Content type processing failed. Verify the schema and ensure all required fields are configured." -``` - -#### Authentication and Configuration -```csharp -StackApiKeyRequired = "Stack api key can not be null." -AccessTokenRequired = "Access token can not be null." -EnvironmentRequired = "Environment can not be null." -AuthenticationNotPresent = "Authentication Not present." -ContentTypeNameRequired = "Please set contentType name." -``` - -#### JSON and Parsing -```csharp -InvalidJsonFormat = "Please provide valid JSON." -ParsingError = "Parsing Error." -``` - -#### Network and Server -```csharp -NoConnectionError = "Connection error" -ServerError = "Server interaction went wrong, Please try again." -NetworkUnavailable = "Network not available." -DefaultError = "Oops! Something went wrong. Please try again." -``` - -#### Cache -```csharp -SavingNetworkCallResponseForCache = "Error while saving network call response." -``` - -#### Initialization -```csharp -ContentstackDefaultMethodNotCalled = "You must called Contentstack.stack() first" -``` - -### CDA API Error Response Format - -The Contentstack CDA returns errors in this JSON format: - -```json -{ - "error_message": "The requested entry doesn't exist.", - "error_code": 141, - "errors": { - "field_name": ["validation message"] - } -} -``` - -`error_code` is Contentstack-specific (not HTTP status). Common codes: -- `141` — Entry not found -- `141` — Asset not found -- `109` — API key invalid -- `103` — Access token invalid -- `129` — Invalid query parameters - -### HTTP Status to Exception Mapping - -| HTTP Status | Typical cause | SDK behavior | -|------------|--------------|-------------| -| 400 | Invalid query params | `QueryFilterException` | -| 401 | Invalid API key / token | `ContentstackException` with StatusCode 401 | -| 404 | Entry / asset not found | `ContentstackException` with StatusCode 404 | -| 422 | Invalid field value | `ContentstackException` with `Errors` dict populated | -| 429 | Rate limit exceeded | `ContentstackException` with StatusCode 429 | -| 500 | Server error | `ContentstackException` with StatusCode 500 | - -### Where GetContentstackError Is Called - -The same static `GetContentstackError` method is replicated (acknowledged code smell) in: -- `Query` — wraps in `QueryFilterException` -- `Entry` — wraps in `EntryException` -- `ContentType` — wraps in `ContentTypeException` -- `Asset` — wraps in `AssetException` -- `AssetLibrary` — wraps in `AssetException` - -When adding a new model, follow the same pattern — copy `GetContentstackError` into the new class (or call the one from `Query` if same namespace/access level permits). - -### Exception with Errors Dictionary - -When the API returns field-level validation errors: - -```csharp -catch (ContentstackException ex) -{ - if (ex.Errors != null) - { - foreach (var field in ex.Errors) - { - // field.Key = field name, field.Value = error messages - Console.WriteLine($"Field '{field.Key}': {field.Value}"); - } - } -} -``` - -### LivePreviewException Trigger Conditions - -Thrown when `LivePreviewConfig.Enable = true` but neither `ManagementToken` nor `PreviewToken` is configured: - -```csharp -if (livePreviewConfig.Enable) -{ - if (string.IsNullOrEmpty(livePreviewConfig.ManagementToken) - && string.IsNullOrEmpty(livePreviewConfig.PreviewToken)) - throw new LivePreviewException(); // uses LivePreviewTokenMissing message -} -``` - -### GlobalFieldException.CreateForIdNull Trigger - -Thrown when `GlobalField(null)` or `GlobalField("")` is called — validated before any HTTP request: - -```csharp -public GlobalField GlobalField(string uid) -{ - if (string.IsNullOrEmpty(uid)) - throw GlobalFieldException.CreateForIdNull(); - // ... -} -``` - -### Adding Messages for New Features — Checklist - -1. Add `public const string` to the appropriate section in `ErrorMessages.cs` -2. Use `{0}` placeholder for `string.Format` when appending exception details -3. Add the factory method on the exception class using `ErrorMessages.FormatExceptionDetails(ex)` for processing errors -4. Never concatenate exception details manually — always use `FormatExceptionDetails()` - ---- - -### Static factory pattern (examples) - -Use factories — not `new XyzException("literal")`: - -```csharp -throw QueryFilterException.Create(innerException); -throw GlobalFieldException.CreateForIdNull(); -throw AssetException.CreateForJsonConversionError(); -throw AssetException.CreateForProcessingError(innerException); -throw EntryException.CreateForProcessingError(innerException); -throw TaxonomyException.CreateForProcessingError(innerException); -throw ContentTypeException.CreateForProcessingError(innerException); -``` - -### Adding a new domain exception - -1. Add the class in `ContentstackExceptions.cs` extending `ContentstackException` with static factories. -2. Add `public const string` entries in `ErrorMessages.cs`; use `{0}` when wrapping `FormatExceptionDetails(innerException)`. - -Example skeleton: - -```csharp -public class MyFeatureException : ContentstackException -{ - public MyFeatureException(string message) : base(message) { } - public MyFeatureException(string message, Exception innerException) : base(message, innerException) { } - - public static MyFeatureException CreateForProcessingError(Exception innerException) - { - return new MyFeatureException( - string.Format(ErrorMessages.MyFeatureProcessingError, - ErrorMessages.FormatExceptionDetails(innerException)), - innerException); - } -} -``` - -### GetContentstackError (implementation sketch) - -`WebException` responses are parsed into `ContentstackException` with `ErrorCode`, `StatusCode`, and `Errors`. The same helper pattern is duplicated on `Query`, `Entry`, `ContentType`, `Asset`, `AssetLibrary` — follow the existing copy when adding a new HTTP-calling model. - -```csharp -internal static ContentstackException GetContentstackError(Exception ex) -{ - var webEx = (WebException)ex; - using var stream = webEx.Response.GetResponseStream(); - string errorMessage = new StreamReader(stream).ReadToEnd(); - JObject data = JObject.Parse(errorMessage); - int errorCode = data["error_code"]?.Value() ?? 0; - HttpStatusCode statusCode = ((HttpWebResponse)webEx.Response).StatusCode; - var errors = data["errors"]?.ToObject>(); - return new ContentstackException(data["error_message"]?.Value()) - { - ErrorCode = errorCode, - StatusCode = statusCode, - Errors = errors - }; -} -``` - -### Standard catch block (SDK internals) - -Preserve `ErrorCode`, `StatusCode`, and `Errors` when re-throwing domain exceptions: - -```csharp -catch (Exception ex) -{ - ContentstackException error = GetContentstackError(ex); - throw new QueryFilterException(error.Message, ex) - { - ErrorCode = error.ErrorCode, - StatusCode = error.StatusCode, - Errors = error.Errors - }; -} -``` - -### Consumer catch order (example) - -```csharp -try { /* await query.Find() */ } -catch (QueryFilterException ex) { /* query validation */ } -catch (ContentstackException ex) { /* API / HTTP */ } -catch (Exception ex) { /* network / unknown */ } -``` - -### ErrorMessages.FormatExceptionDetails - -```csharp -ErrorMessages.FormatExceptionDetails(innerException) -``` diff --git a/skills/models-and-serialization/SKILL.md b/skills/models-and-serialization/SKILL.md deleted file mode 100644 index c7a49100..00000000 --- a/skills/models-and-serialization/SKILL.md +++ /dev/null @@ -1,248 +0,0 @@ ---- -name: models-and-serialization -description: Model and serialization patterns for the Contentstack .NET SDK — Entry/Asset shape, catch-all Object dictionary, generic Fetch/Find projections, CSJsonConverter attribute-driven converter registration, EntryJsonConverter/AssetJsonConverter, ContentstackCollection, JsonProperty for API name mismatches, entry variants. Use when adding new model types, writing JSON converters, working with entry variants, embedded assets, or modifying serialization settings. ---- - -# Models and Serialization - -## When to use - -- New models, converters, or `JsonProperty` mappings. -- Entry variants, embedded references, RTE/modular blocks behavior. -- Tuning `SerializerSettings`. - -## Invariants - -- CDA JSON is **snake_case** — use **`[JsonProperty("snake_case")]`** on mapped properties; do not rely on default PascalCase naming. -- Custom converters: **`[CSJsonConverter("Name")]`** on the model + **`JsonConverter`** implementation in **`Contentstack.Core/Internals/`**. -- New converters are registered automatically at client init (see **CSJsonConverter registration flow** below). -- Use **`Newtonsoft.Json`** only — not `System.Text.Json`. - -## Serialization Patterns Reference - -### CSJsonConverter Registration Flow - -At `ContentstackClient` construction time: - -``` -1. Scan all assemblies in current AppDomain -2. Find all types with [CSJsonConverter("ConverterName")] attribute -3. Find the JsonConverter class with matching name in Contentstack.Core.Internals -4. Instantiate it and add to SerializerSettings.Converters -5. All subsequent Fetch/Find calls use these converters -``` - -This means **converter registration is automatic** — adding the attribute and converter class is all that's required. - -### EntryJsonConverter Internals - -`EntryJsonConverter` handles the nested entry JSON structure from the CDA: - -```json -{ - "uid": "blt123", - "title": "My Entry", - "publish_details": { "environment": "production", "locale": "en-us", "time": "...", "user": "..." }, - "locale": "en-us", - "_metadata": { ... }, - "custom_field": "value", - "reference_field": [{ "uid": "blt456", "_content_type_uid": "blog" }] -} -``` - -The converter: -1. Reads the raw `JObject` -2. Maps known fields to strongly-typed properties (`Uid`, `Title`, `PublishDetails`, etc.) -3. Puts all remaining fields into `entry.Object` (the catch-all dictionary) - -### AssetJsonConverter Internals - -Similar pattern for assets — maps `uid`, `title`, `url`, `content_type`, `file_size`, `filename`, `tags` to typed properties; remaining fields to `asset.Object`. - -### parseJObject\ in Query - -After `HttpRequestHandler.ProcessRequest` returns a JSON string, `Query.parseJObject` does: - -```csharp -JObject jObject = JObject.Parse(responseString); - -// For entry queries -JArray entries = (JArray)jObject["entries"]; -collection.Items = entries.ToObject>(client.Serializer); -collection.Count = jObject["count"]?.Value() ?? 0; -collection.Skip = (int)UrlQueries.GetValueOrDefault("skip", 0); -collection.Limit = (int)UrlQueries.GetValueOrDefault("limit", 100); -``` - -The `"entries"` token name is fixed by the CDA response format. Asset library uses `"assets"`. - -### JsonProperty Mapping Reference - -Key mappings already in the codebase: - -| JSON field | C# property | Model | -|-----------|-------------|-------| -| `publish_details` | `PublishDetails` | `Entry` | -| `_metadata` | `Metadata` / `_metadata` | `Entry` | -| `content_type` | `ContentType` | `Asset` | -| `file_size` | `FileSize` | `Asset` | -| `filename` | `FileName` | `Asset` | -| `created_at` | `CreatedAt` | various | -| `updated_at` | `UpdatedAt` | various | -| `created_by` | `CreatedBy` | various | - -### Newtonsoft.Json Settings Defaults - -The SDK uses default `JsonSerializerSettings` with no special configuration unless consumers override `client.SerializerSettings`. This means: -- `NullValueHandling.Include` (nulls are included) -- No date format override (ISO 8601 by default) -- No contract resolver override (property names as-is, so `[JsonProperty]` is required) -- No type name handling - -### Handling Embedded RTE Items - -RTE (Rich Text Editor) fields with embedded entries/assets are processed via `contentstack.utils` NuGet package. The `Entry` model surfaces the raw RTE JSON; consumers call the utils library to resolve embedded references: - -```csharp -// RTE field value from entry.Object["rte_field"] -// Pass to contentstack.utils for resolution -Utils.RenderContent(content, entryEmbeds); -``` - -### Deep Reference Deserialization - -When `IncludeReference()` is called, the CDA returns nested objects inside the entry JSON. These are deserialized as nested dictionaries in `entry.Object` or as typed sub-objects when the consumer POCO uses `[JsonProperty]` on the reference field: - -```csharp -public class BlogPost -{ - [JsonProperty("author")] - public Author Author { get; set; } // auto-deserialized if CDA returns expanded ref -} -``` - -If the reference is not expanded (not included), it will be an array of `{"uid": "...", "_content_type_uid": "..."}` objects. - -### Modular Blocks Deserialization - -Modular blocks are returned as JSON arrays of objects, each with a `_content_type_uid` discriminator: - -```json -"modular_blocks": [ - { "block_a": { "field1": "value" } }, - { "block_b": { "field2": "value" } } -] -``` - -Map with a `List>` or use a custom converter with a discriminator switch on the first key. - -### ContentstackCollection Parsing Edge Cases - -- `count` field only present when `IncludeCount()` is set on the query -- `entries` array is present even when empty (`[]`), never `null` -- `skip` and `limit` in the response may differ from what was requested if the CDA has its own limits - ---- - -### Entry model shape (quick reference) - -```csharp -// Strongly-typed fields (typical) -entry.Uid; entry.Title; entry.Tags; entry.Metadata; entry.PublishDetails; - -// Catch-all for arbitrary content type fields -entry.Object // Dictionary -``` - -```csharp -var price = entry.Object["price"]; -var color = entry.Object["color"] as string; -``` - -### Fetch and Find with typed POCOs - -Prefer typed models over `entry.Object` for structured access: - -```csharp -public class Product -{ - [JsonProperty("title")] - public string Title { get; set; } - - [JsonProperty("price")] - public decimal Price { get; set; } - - [JsonProperty("uid")] - public string Uid { get; set; } -} - -var result = await client.ContentType("product").Query().Find(); -var product = await client.ContentType("product").Entry("uid").Fetch(); -``` - -Deserialization uses `client.Serializer` (from `client.SerializerSettings`). - -### JsonProperty — always map snake_case - -The CDA uses `snake_case`. Newtonsoft defaults to PascalCase property names. Always annotate: - -```csharp -[JsonProperty("publish_details")] -public object PublishDetails { get; set; } -``` - -Without `[JsonProperty]`, deserialization looks for the wrong JSON keys. - -### CSJsonConverter on models - -```csharp -[CSJsonConverter("EntryJsonConverter")] -public class Entry { ... } -``` - -Converters live in `Contentstack.Core/Internals/`. See [CSJsonConverter Registration Flow](#csjsonconverter-registration-flow). - -### ContentstackCollection shape - -```csharp -public class ContentstackCollection -{ - public IEnumerable Items { get; } - public int Count { get; } - public int Skip { get; } - public int Limit { get; } -} -``` - -Parsed from `"entries"` or `"assets"` in `Query.parseJObject` (see [parseJObject\ in Query](#parsejobjectt-in-query)). - -### Asset model (common properties) - -`Uid`, `Title`, `Url`, `ContentType`, `FileSize`, `FileName`, `Tags`, plus `Object` for other fields. `AssetJsonConverter` handles nested JSON; `AssetLibrary.FetchAll()` returns `ContentstackCollection`. - -### Entry variants - -```csharp -entry.SetVariant("variant_uid"); -var result = await entry.Fetch(); -``` - -Uses `_variant` and the `x-cs-variant-uid` header path as implemented on `Entry`. - -### Adding a new model type (checklist) - -1. Add class under `Contentstack.Core/Models/` with `[JsonProperty]` on API fields and optional `Object` catch-all. -2. If custom deserialization is required, add `internal class MyModelJsonConverter : JsonConverter` in `Internals/`, implement `ReadJson` / `WriteJson` as needed (delivery is typically read-heavy). -3. Mark the model with `[CSJsonConverter("MyModelJsonConverter")]`. -4. Expose via `ContentstackClient` factory methods if it is a first-class API surface. - -### SerializerSettings customization - -Consumers may adjust before calls: - -```csharp -client.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; -client.SerializerSettings.DateFormatString = "yyyy-MM-dd"; -``` - -`Serializer` is rebuilt from `SerializerSettings` when needed. diff --git a/skills/query-building/SKILL.md b/skills/query-building/SKILL.md deleted file mode 100644 index fa9bbd80..00000000 --- a/skills/query-building/SKILL.md +++ /dev/null @@ -1,312 +0,0 @@ ---- -name: query-building -description: Query building patterns for the Contentstack .NET SDK — two-dictionary architecture (QueryValueJson + UrlQueries), Mongo-style operators, fluent chaining, Exec() merge order, pagination (skip/limit), sync API pagination tokens, taxonomy query path. Use when adding query operators or filters, modifying Query.cs, working on pagination, debugging query parameter serialization, or building entry/asset queries. ---- - -# Query Building - -## When to use - -- Adding or changing operators on `Query`, `AssetLibrary`, or related fluent APIs. -- Debugging why a filter or URL param is wrong. -- Pagination, sync, or taxonomy entry queries. - -## Invariants - -- Field-level Mongo filters live in **`QueryValueJson`**; top-level API params (locale, skip, limit, includes, etc.) live in **`UrlQueries`**. -- **`Exec()`** merges `environment`, optional live-preview keys, then `query` (from `QueryValueJson`), then each `UrlQueries` entry—later keys override earlier ones. -- New filter methods return **`this`** and validate keys with **`QueryFilterException`** where appropriate. -- Never set **`environment`** manually in `UrlQueries`—`Exec()` injects it from `Config`. - -## Exec() merge order (compact) - -```csharp -mainJson["environment"] = stack.Config.Environment; -// live preview keys if enabled -if (QueryValueJson.Count > 0) - mainJson["query"] = QueryValueJson; -foreach (var kvp in UrlQueries) - mainJson[kvp.Key] = kvp.Value; -// → HttpRequestHandler.ProcessRequest(..., BodyJson) -``` - -## Query Patterns Reference - -### Quick reference: QueryValueJson operators - -| Method | Operator | Example JSON shape | -|--------|----------|-------------------| -| `Where(key, value)` | direct equality | `{"field": "value"}` | -| `NotEqualTo(key, value)` | `$ne` | `{"field":{"$ne":"x"}}` | -| `ContainedIn(key, values)` | `$in` | `{"field":{"$in":["a","b"]}}` | -| `NotContainedIn(key, values)` | `$nin` | `{"field":{"$nin":["a"]}}` | -| `Exists(key)` | `$exists: true` | `{"field":{"$exists":true}}` | -| `NotExists(key)` | `$exists: false` | `{"field":{"$exists":false}}` | -| `GreaterThan(key, value)` | `$gt` | `{"field":{"$gt":10}}` | -| `LessThan(key, value)` | `$lt` | `{"field":{"$lt":10}}` | -| `Regex(key, pattern)` | `$regex` | `{"field":{"$regex":"^blt"}}` | -| `And(queries[])` | `$and` | array of query objects | -| `Or(queries[])` | `$or` | array of query objects | - -### Quick reference: UrlQueries keys - -| Method | Key | Notes | -|--------|-----|-------| -| `SetLocale(locale)` | `locale` | prefer over obsolete `Language` enum | -| `Skip(n)` | `skip` | pagination offset | -| `Limit(n)` | `limit` | pagination page size | -| `IncludeSchema()` | `include_schema` | `true` | -| `IncludeCount()` | `include_count` | `true` | -| `Tags(tags[])` | `tags` | `string[]` → repeated param | -| `Only(fields[])` | `only[BASE][]` | field projection | -| `Except(fields[])` | `except[BASE][]` | field exclusion | -| `IncludeReference(key)` | `include[]` | reference expansion | - -### Extending Query.cs (new fluent methods) - -#### New Mongo-style filter operator - -```csharp -// Pattern used by all existing operators in Query.cs -public Query MyNewOperator(string key, object value) -{ - if (key == null) - throw QueryFilterException.Create(new ArgumentNullException(nameof(key))); - - if (!QueryValueJson.ContainsKey(key)) - QueryValueJson[key] = new Dictionary(); - - ((Dictionary)QueryValueJson[key])["$myop"] = value; - return this; // always return this for fluent chaining -} -``` - -#### New URL-level parameter - -```csharp -public Query MyParam(string value) -{ - UrlQueries["my_param"] = value; - return this; -} -``` - -### Terminal operations - -```csharp -Task> result = await query.Find(); -Task result = await query.FindOne(); -Task count = await query.Count(); -``` - -### Paginating Find results (entries) - -```csharp -// ContentstackCollection response shape -result.Items // IEnumerable -result.Count // total count (requires IncludeCount()) -result.Skip // current offset -result.Limit // current page size - -// Paging loop -int skip = 0, limit = 100; -ContentstackCollection page; -do { - page = await query.Skip(skip).Limit(limit).Find(); - // process page.Items - skip += limit; -} while (page.Items.Count() == limit); -``` - -### SyncStack short reference - -When using `SyncRecursive` / sync APIs: - -```csharp -SyncStack syncResult = await client.SyncRecursive(parameters); -// SyncStack.PaginationToken — non-null while more pages exist -// SyncStack.SyncToken — final token for next delta sync -``` - -(See [Sync API Patterns](#sync-api-patterns) below for full sync flows.) - -### Complete Operator Categories - -#### Comparison Operators - -```csharp -query.Where("title", "My Entry") // direct equality -query.NotEqualTo("status", "draft") // $ne -query.GreaterThan("price", 100) // $gt -query.GreaterThanEqualTo("price", 100) // $gte -query.LessThan("price", 200) // $lt -query.LessThanEqualTo("price", 200) // $lte -``` - -#### Array / Set Operators - -```csharp -query.ContainedIn("color", new[] {"red", "blue"}) // $in -query.NotContainedIn("color", new[] {"green"}) // $nin -``` - -#### Existence Operators - -```csharp -query.Exists("field_name") // $exists: true -query.NotExists("field_name") // $exists: false -``` - -#### String Operators - -```csharp -query.Regex("uid", "^blt[a-zA-Z0-9]+$") // $regex -query.Regex("title", "^hello", "i") // $regex with $options modifier -``` - -#### Logical Operators - -```csharp -// And / Or take Query[] — each sub-query builds its own QueryValueJson -var q1 = client.ContentType("ct").Query().Where("color", "red"); -var q2 = client.ContentType("ct").Query().Where("size", "large"); -query.And(new[] { q1, q2 }); // $and -query.Or(new[] { q1, q2 }); // $or -``` - -### Reference / Include Patterns - -```csharp -query.IncludeReference("reference_field"); // expand single reference -query.IncludeReference(new[] {"ref1", "ref2"}); // expand multiple -query.IncludeSchema(); // include content type schema -query.IncludeCount(); // include total count in response -query.IncludeOwner(); // include entry owner info -query.IncludeMetadata(); // include entry metadata -``` - -### Field Projection - -```csharp -query.Only(new[] {"title", "uid", "price"}); // return only these fields -query.Except(new[] {"body", "image"}); // exclude these fields - -// For referenced fields -query.OnlyWithReferenceUid(new[] {"title"}, "reference_field"); -query.ExceptWithReferenceUid(new[] {"body"}, "reference_field"); -``` - -### Ordering - -```csharp -query.OrderByAscending("title"); -query.OrderByDescending("created_at"); -``` - -### Locale and Environment - -```csharp -query.SetLocale("en-us"); // preferred — string locale code -// Environment is injected automatically from Config in Exec() -// Never set "environment" manually in UrlQueries -``` - -### Exec() Implementation Detail - -The full merge performed in `Query.Exec()` before calling `HttpRequestHandler`: - -```csharp -var mainJson = new Dictionary(); - -// 1. Environment (always injected from Config) -mainJson["environment"] = ContentTypeInstance.StackInstance.Config.Environment; - -// 2. Live preview headers (if enabled and content type matches) -if (livePreviewActive) -{ - mainJson["live_preview"] = livePreviewConfig.LivePreview; - mainJson["authorization"] = livePreviewConfig.ManagementToken; - // or mainJson["preview_token"] = livePreviewConfig.PreviewToken; -} - -// 3. Mongo-style query filter (only if non-empty) -if (QueryValueJson.Count > 0) - mainJson["query"] = QueryValueJson; - -// 4. All UrlQueries (locale, skip, limit, includes, projections, etc.) -foreach (var kvp in UrlQueries) - mainJson[kvp.Key] = kvp.Value; -``` - -### How QueryValueJson Is Serialized - -`Dictionary` values are JSON-serialized by `HttpRequestHandler`: - -``` -QueryValueJson = { "title": {"$ne": "Draft"}, "color": {"$in": ["red","blue"]} } -→ query={"title":{"$ne":"Draft"},"color":{"$in":["red","blue"]}} -``` - -This becomes a single `query=` URL parameter with the JSON as its value. - -### Sync API Patterns - -```csharp -// Initial sync (all published content) -SyncStack result = await client.Sync(new SyncStack() { Type = SyncType.entry_published }); - -// Paginated initial sync -SyncStack result = await client.SyncRecursive(parameters); -// result.PaginationToken — continue paginating if not null -// result.SyncToken — use for next delta sync - -// Delta sync (changes since last sync) -SyncStack delta = await client.SyncToken(result.SyncToken); - -// Manual pagination loop -SyncStack page = initialResult; -while (page.PaginationToken != null) -{ - page = await client.SyncPaginationToken(page.PaginationToken); - // process page.Items -} -``` - -### Taxonomy Query Patterns - -```csharp -// Create taxonomy query via client.Taxonomies() -Query taxonomyQuery = client.Taxonomies(); - -// Same filter methods apply -taxonomyQuery.Where("taxonomies.animals", new Dictionary { - { "$eq", "mammals" } -}); - -var results = await taxonomyQuery.Find(); -``` - -**URL paths** - -- Normal content-type query: `{stack.Config.BaseUrl}/content_types/{uid}/entries` -- Taxonomy query (`client.Taxonomies()`): `{stack.Config.BaseUrl}/taxonomies/entries` - -Filter and pagination APIs are the same; only the base path differs. - -### AssetLibrary Query Patterns - -```csharp -AssetLibrary assetLib = client.Assets(); -assetLib.Skip(0).Limit(100); -assetLib.IncludeCount(); -assetLib.SetLocale("en-us"); -ContentstackCollection assets = await assetLib.FetchAll(); -``` - -### Common Mistakes to Avoid - -- Never set `"environment"` key manually in `UrlQueries` — it is always injected from `Config.Environment` in `Exec()` -- Never call `ProcessRequest` directly — always go through model methods (`Find`, `Fetch`, etc.) -- Never modify `QueryValueJson` from outside `Query` — use the public fluent methods -- `And()` / `Or()` take full `Query` instances, not raw dictionaries -- `string[]` values in `UrlQueries` become repeated URL params, not JSON arrays — use for tags, not Mongo operators diff --git a/skills/sdk-core-patterns/SKILL.md b/skills/sdk-core-patterns/SKILL.md deleted file mode 100644 index b6e36e3a..00000000 --- a/skills/sdk-core-patterns/SKILL.md +++ /dev/null @@ -1,241 +0,0 @@ ---- -name: sdk-core-patterns -description: Core architecture of the Contentstack .NET Delivery SDK — namespaces, ContentstackClient factory, Config/ContentstackOptions, HttpRequestHandler (HttpWebRequest GET), plugin hooks, ASP.NET Core DI, and multi-targeting. Use when working on new SDK features, adding model types, wiring DI, understanding request flow, or onboarding to the codebase. ---- - -# SDK Core Patterns - -## When to use - -- Onboarding, request flow, or where types live. -- Changing HTTP behavior, config, plugins, or DI. -- Anything that must go through `HttpRequestHandler`. - -## Namespace map - -| Namespace | Purpose | -|-----------|---------| -| `Contentstack.Core` | `ContentstackClient` — root entry point | -| `Contentstack.Core.Models` | `Query`, `Entry`, `Asset`, `AssetLibrary`, `ContentType`, `GlobalField`, `GlobalFieldQuery`, `Taxonomy`, `SyncStack`, `ContentstackCollection` | -| `Contentstack.Core.Configuration` | `ContentstackOptions`, `Config` (internal), `LivePreviewConfig` | -| `Contentstack.Core.Internals` | `HttpRequestHandler`, exceptions, enums, converters, constants — all `internal` | -| `Contentstack.Core.Interfaces` | `IContentstackPlugin` | -| `Contentstack.AspNetCore` | `IServiceCollectionExtensions` for DI registration | - -## Invariants - -- **`ContentstackClient`** is the only public entry point for creating stack operations. -- **All HTTP** goes through **`HttpRequestHandler.ProcessRequest`** — no `HttpClient`, no bypassing for feature work. -- **GET + query string** for requests; plugins run `OnRequest` / `OnResponse` around the call. -- **Multi-target:** `netstandard2.0`, `net47`, `net472` — no `System.Text.Json`; Newtonsoft.Json throughout. -- **Version:** `Directory.Build.props` only — do not set `` in individual `.csproj` files. - -## ContentstackClient (compact) - -```csharp -var client = new ContentstackClient(options); - -client.ContentType("uid").Query().Find(); -client.ContentType("uid").Entry("uid"); -client.Assets(); client.Asset("uid"); -client.GlobalField("uid"); client.GlobalFields(); -client.Taxonomies(); client.Sync(...); -``` - -## Options → Config → BaseUrl - -`ContentstackOptions` → internal `Config` → `BaseUrl` (region + host + version). Required: `ApiKey`, `DeliveryToken`, `Environment`. Region table, live preview host switching, and internal client state are in **SDK architecture reference** below. ASP.NET Core registration is covered under **ASP.NET Core integration** below. - - -## SDK Architecture Reference - -### Full Request Flow - -``` -ContentstackClient - └── ContentType("uid") → ContentType - └── Query() → Query - └── Find() → Query.Exec() - └── HttpRequestHandler.ProcessRequest(url, headers, bodyJson) - ├── Serialize BodyJson → query string - ├── Create HttpWebRequest (GET) - ├── Set headers (api_key, access_token, branch, x-user-agent) - ├── foreach plugin: OnRequest(client, request) - ├── await request.GetResponseAsync() - ├── foreach plugin: OnResponse(client, request, response, body) - └── return JSON string → parsed in Query.parseJObject -``` - -### Config.BaseUrl Composition - -``` -Protocol Region Code Host Version -"https://" "" "cdn.contentstack.io" "/v3" → US (default) -"https://" "eu-" "cdn.contentstack.com" "/v3" → EU -"https://" "azure-na-" "cdn.contentstack.com" "/v3" → AZURE_NA -"https://" "azure-eu-" "cdn.contentstack.com" "/v3" → AZURE_EU -"https://" "gcp-na-" "cdn.contentstack.com" "/v3" → GCP_NA -"https://" "au-" "cdn.contentstack.com" "/v3" → AU -``` - -`HostURL` defaults to `cdn.contentstack.io` for US, `cdn.contentstack.com` for all other regions. - -### Live Preview URL Resolution - -When `LivePreviewConfig.Enable == true` and `LivePreview != "init"` and `ContentTypeUID` matches the queried content type, `Config.getBaseUrl()` returns the live preview host instead of `BaseUrl`: - -``` -"https://{livePreviewConfig.Host}/{version}" -``` - -Additional headers injected: `live_preview`, `authorization` (management token) or `preview_token`, optional `release_id`, `preview_timestamp`. - -### Query String Serialization Rules (HttpRequestHandler) - -| Value type | Serialization | -|-----------|--------------| -| `string` | `key=value` | -| `string[]` | `key=v1&key=v2` (repeated) | -| `Dictionary` | `key={"$in":["a","b"]}` (JSON) | -| Other | `key=value.ToString()` | - -### ContentstackClient Internal State - -```csharp -internal string StackApiKey // from options -internal Dictionary _Headers // api_key, access_token/delivery_token -internal Dictionary _StackHeaders // shared across requests -internal LivePreviewConfig LivePreviewConfig // null if not configured -public List Plugins // empty by default -public JsonSerializerSettings SerializerSettings // for Fetch/Find -internal JsonSerializer Serializer // created from SerializerSettings -``` - -### How Models Get Stack Context - -All model constructors are `internal`. `ContentstackClient` methods set back-references: - -```csharp -// ContentType.cs internal wiring -internal ContentstackClient StackInstance { get; set; } - -// Query.cs -private ContentType ContentTypeInstance { get; set; } // for entry path -private ContentstackClient TaxonomyInstance { get; set; } // for taxonomy path -``` - -Models build their URL from `ContentTypeInstance.StackInstance.Config.BaseUrl` at call time (lazy). - -### Plugin Implementation Pattern - -```csharp -public class MyPlugin : IContentstackPlugin -{ - public Task OnRequest(ContentstackClient stack, HttpWebRequest request) - { - // Mutate request (add headers, log, etc.) - return Task.FromResult(request); - } - - public Task OnResponse(ContentstackClient stack, HttpWebRequest request, - HttpWebResponse response, string responseString) - { - // Inspect/transform response body string - return Task.FromResult(responseString); - } -} - -// Register -client.Plugins.Add(new MyPlugin()); -``` - -### ContentstackRegion Enum Values - -```csharp -public enum ContentstackRegion { US, EU, AZURE_NA, AZURE_EU, GCP_NA, AU } -``` - -`ContentstackRegionCode` (internal enum) maps to URL prefixes: `eu`, `azure_na`, `azure_eu`, `gcp_na`, `au`. Underscores are replaced with hyphens in the URL. - -### Key NuGet Dependencies (Contentstack.Core.csproj) - -| Package | Version | Purpose | -|---------|---------|---------| -| `Newtonsoft.Json` | 13.0.4 | All JSON serialization | -| `Microsoft.Extensions.Options` | 8.0.2 | `IOptions` | -| `Markdig` | 0.36.2 | Markdown processing in RTE fields | -| `contentstack.utils` | 1.0.6 | RTE embedded item resolution | - -### Solution Layout - -``` -Contentstack.Net.sln -├── Contentstack.Core/ ← Main SDK package (contentstack.csharp on NuGet) -├── Contentstack.AspNetCore/ ← DI extension (contentstack.aspnetcore on NuGet) -├── Contentstack.Core.Tests/ ← Integration tests (net7.0, hits live API) -└── Contentstack.Core.Unit.Tests/ ← Unit tests (no network) -``` - -Version shared via `Directory.Build.props` → `2.26.0` (or current). - -### Supporting internals (maintainers) - -When debugging HTTP, serialization edges, or multi-target behavior, these types in `Contentstack.Core/Internals/` are often involved. They are not public API. - -| Area | Types / files | -|------|----------------| -| Async helpers around `HttpWebRequest` | `WebRequestAsyncExtensions.cs` | -| Version string / user-agent composition | `VersionUtility.cs`, `StackOutput.cs` | -| JSON / value coercion helpers | `ContentstackConvert.cs` | -| Language / locale enums | `LanguageEnums.cs` | - -Prefer changing behavior through `HttpRequestHandler`, `Config`, and public models rather than exposing these types. - -## ASP.NET Core integration - -Source: [`Contentstack.AspNetCore/IServiceCollectionExtensions.cs`](../../../Contentstack.AspNetCore/IServiceCollectionExtensions.cs). - -### Registration - -Two overloads register the same services: - -```csharp -public static IServiceCollection AddContentstack(this IServiceCollection services, IConfigurationRoot configuration) -public static IServiceCollection AddContentstack(this IServiceCollection services, IConfiguration configuration) -``` - -Both: - -1. `services.AddOptions()` -2. `services.Configure(configuration.GetSection("ContentstackOptions"))` -3. `services.TryAddTransient()` - -### Configuration section - -Bind options from configuration using section name **`ContentstackOptions`**: - -```json -{ - "ContentstackOptions": { - "ApiKey": "...", - "DeliveryToken": "...", - "Environment": "..." - } -} -``` - -Adjust property names to match [`ContentstackOptions`](../../../Contentstack.Core/Configuration/ContentstackOptions.cs) public properties. - -### Service lifetime - -`ContentstackClient` is registered as **transient** (`TryAddTransient`). Each resolution gets a new instance; use this when injecting into short-lived scopes or when the app expects a fresh client per operation. - -### Usage in app code - -Inject `ContentstackClient` or `IOptions` as needed after calling `AddContentstack` in `Program.cs` / `Startup.cs`: - -```csharp -services.AddContentstack(configuration); -``` - -Ensure `configuration` includes the `ContentstackOptions` section (e.g. `appsettings.json`, environment variables, user secrets). diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md deleted file mode 100644 index e76b7bce..00000000 --- a/skills/testing/SKILL.md +++ /dev/null @@ -1,334 +0,0 @@ ---- -name: testing -description: Testing patterns for the Contentstack .NET SDK — unit tests using AutoFixture and private field reflection (no network), and integration tests using IntegrationTestBase, TestDataHelper, LogArrange/LogAct/LogAssert, xUnit traits, and RequestLoggingPlugin. Use when writing new tests, adding coverage for a new feature, debugging integration test failures, or understanding the test structure. ---- - -# Testing - -## When to use - -- New unit tests for `Query` or models (reflection on `QueryValueJson` / `UrlQueries`). -- New or failing integration tests against the live API. - -## Projects - -| Project | Framework | Purpose | -|---------|-----------|---------| -| `Contentstack.Core.Unit.Tests` | xUnit + AutoFixture | No network; assert internal state via reflection | -| `Contentstack.Core.Tests` | xUnit, net7.0 | Live API; requires `app.config` (or equivalent) credentials | - -## Invariants - -- Unit tests: **no real network** — use AutoFixture for options, reflection for private dictionaries. -- Integration tests: **never commit secrets** — credentials from `app.config` / env locally. -- New `Query` methods: unit test operators + null/invalid cases; integration test when behavior is API-bound. - -## Testing Patterns Reference - -### Complete Unit Test Template - -```csharp -using System.Collections.Generic; -using System.Reflection; -using AutoFixture; -using Contentstack.Core; -using Contentstack.Core.Configuration; -using Contentstack.Core.Models; -using Contentstack.Core.Internals; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Contentstack.Core.Unit.Tests -{ - public class MyFeatureUnitTests - { - private readonly IFixture _fixture = new Fixture(); - private ContentstackClient _client; - - public MyFeatureUnitTests() - { - var options = new ContentstackOptions() - { - ApiKey = _fixture.Create(), - DeliveryToken = _fixture.Create(), - Environment = _fixture.Create() - }; - _client = new ContentstackClient(new OptionsWrapper(options)); - } - - private Query CreateQuery(string contentTypeId = "source") - => _client.ContentType(contentTypeId).Query(); - - // Helper: get private QueryValueJson - private Dictionary GetQueryValueJson(Query query) - { - var field = typeof(Query).GetField("QueryValueJson", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); - return (Dictionary)field?.GetValue(query); - } - - // Helper: get private UrlQueries - private Dictionary GetUrlQueries(Query query) - { - var field = typeof(Query).GetField("UrlQueries", - BindingFlags.NonPublic | BindingFlags.Instance); - return (Dictionary)field?.GetValue(query); - } - - [Fact] - public void MyOperator_AddsCorrectQueryParameter() - { - var query = CreateQuery(); - var key = _fixture.Create(); - - var result = query.MyOperator(key, "value"); - - Assert.Equal(query, result); // fluent return - var qvj = GetQueryValueJson(query); - Assert.True(qvj.ContainsKey(key)); - var inner = qvj[key] as Dictionary; - Assert.True(inner.ContainsKey("$myop")); - Assert.Equal("value", inner["$myop"]); - } - - [Fact] - public void MyUrlParam_AddsToUrlQueries() - { - var query = CreateQuery(); - - query.SetLocale("en-us"); - - var urlQueries = GetUrlQueries(query); - Assert.Equal("en-us", urlQueries["locale"]); - } - - [Fact] - public void MyOperator_WithNullKey_ThrowsQueryFilterException() - { - var query = CreateQuery(); - Assert.Throws(() => query.MyOperator(null, "value")); - } - - [Fact] - public void MyOperator_WithEmptyKey_ThrowsQueryFilterException() - { - var query = CreateQuery(); - Assert.Throws(() => query.MyOperator(string.Empty, "value")); - } - - [Fact] - public void MyOperator_ReturnsQueryForChaining() - { - var query = CreateQuery(); - var result = query - .MyOperator("field1", "value1") - .MyOperator("field2", "value2"); - Assert.Equal(query, result); - } - } -} -``` - -### Complete Integration Test Template - -```csharp -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; -using Contentstack.Core.Models; -using Contentstack.Core.Tests.Helpers; - -namespace Contentstack.Core.Tests.Integration.MyFeatureTests -{ - public class MyFeatureComprehensiveTest : IntegrationTestBase - { - public MyFeatureComprehensiveTest(ITestOutputHelper output) : base(output) - { - } - - [Fact(DisplayName = "MyFeature - BasicOperation ReturnsExpectedResult")] - public async Task MyFeature_BasicOperation_ReturnsExpectedResult() - { - // Arrange - LogArrange("Setting up basic operation test"); - LogContext("ContentType", TestDataHelper.SimpleContentTypeUid); - - var client = CreateClient(); - var query = client.ContentType(TestDataHelper.SimpleContentTypeUid).Query(); - - // Act - LogAct("Executing query with my feature"); - query.MyOperator("uid", "someValue"); - var result = await query.Find(); - - // Assert - LogAssert("Verifying response structure"); - TestAssert.NotNull(result); - TestAssert.NotNull(result.Items); - TestAssert.True(result.Count >= 0, "Count should be non-negative"); - } - - [Fact(DisplayName = "MyFeature - WithInvalidParams ThrowsException")] - public async Task MyFeature_WithInvalidParams_ThrowsException() - { - // Arrange - LogArrange("Setting up error scenario"); - var client = CreateClient(); - - // Act & Assert - LogAct("Executing with invalid parameters"); - await Assert.ThrowsAsync(async () => - { - await client.ContentType("nonexistent_type_12345") - .Query().Find(); - }); - } - } -} -``` - -### Existing Unit Test Files — What Each Covers - -| File | Covers | -|------|--------| -| `QueryUnitTests.cs` | All `Query` filter/operator methods, UrlQueries params | -| `EntryUnitTests.cs` | `Entry` field access, URL construction, header setting | -| `AssetUnitTests.cs` | `Asset` model fields | -| `AssetLibraryUnitTests.cs` | `AssetLibrary` query params, Skip/Limit | -| `ContentstackClientUnitTests.cs` | Client initialization, header injection, factory methods | -| `ContentstackOptionsUnitTests.cs` | Options defaults, validation | -| `ContentstackExceptionUnitTests.cs` | Exception hierarchy, factory methods, message content | -| `ConfigUnitTests.cs` | BaseUrl composition, region codes | -| `ContentstackRegionUnitTests.cs` | Region enum → URL prefix mapping | -| `GlobalFieldUnitTests.cs` | GlobalField ID validation, URL construction | -| `GlobalFieldQueryUnitTests.cs` | GlobalFieldQuery filter methods | -| `TaxonomyUnitTests.cs` | Taxonomy query path | -| `JsonConverterUnitTests.cs` | CSJsonConverter attribute registration | -| `LivePreviewConfigUnitTests.cs` | LivePreviewConfig validation | - -### Existing Integration Test Folders — What Each Covers - -| Folder | Covers | -|--------|--------| -| `QueryTests/` | Query operators, complex filters, field queries, includes | -| `EntryTests/` | Entry fetch, field projection, references | -| `GlobalFieldsTests/` | Global field schemas, nested global fields | -| `SyncTests/` | Sync API, pagination tokens, delta sync | -| `AssetTests/` | Asset fetch, asset library queries | -| `ContentTypeTests/` | Content type fetch, schema queries | -| `LocalizationTests/` | Locale filtering, locale fallback chains | -| `PaginationTests/` | Skip/Limit behavior, count accuracy | -| `ErrorHandling/` | API error codes, exception types, invalid params | -| `LivePreview/` | Live preview URL routing, token headers | -| `ModularBlocksTests/` | Modular block field deserialization | -| `MetadataTests/` | Entry metadata fields | -| `TaxonomyTests/` | Taxonomy query path, taxonomy filtering | -| `VariantsTests/` | Entry variant headers, variant content | -| `BranchTests/` | Branch header injection | - -### MockHttpHandler Pattern (Unit Tests) - -When you need to mock HTTP responses without network: - -```csharp -// In Mokes/MockHttpHandler.cs — extend for new mock scenarios -// MockResponse.cs — add JSON fixture strings for new response shapes -// MockInfrastructureTest.cs — base class wiring MockHttpHandler into client -``` - -### TestAssert Wrappers - -Use `TestAssert.*` instead of raw `Assert.*` in integration tests — they log assertion context to `ITestOutputHelper`: - -```csharp -TestAssert.NotNull(result); -TestAssert.Equal(expected, actual); -TestAssert.True(condition, "failure message"); -TestAssert.False(condition, "failure message"); -TestAssert.IsAssignableFrom>(result.Items); -TestAssert.Matches("^blt[a-zA-Z0-9]+$", entry.Uid); -``` - -### app.config Keys for Integration Tests - -Integration tests read config from `Contentstack.Core.Tests/app.config`: - -```xml - - - - - - - - - - -``` - -Never commit real credentials. Use environment variables or a secrets manager in CI. - -### Running Tests - -```bash -# Unit tests only (no credentials needed) -dotnet test Contentstack.Core.Unit.Tests/ - -# Integration tests (requires app.config with valid credentials) -dotnet test Contentstack.Core.Tests/ - -# Run specific category -dotnet test --filter "Category=RetryIntegration" - -# Run specific test class -dotnet test --filter "FullyQualifiedName~QueryOperatorsComprehensiveTest" -``` - -### Mokes folder (unit tests) - -`Contentstack.Core.Unit.Tests/Mokes/`: - -- `MockHttpHandler.cs` — intercepts HTTP without network -- `MockResponse.cs` — sample JSON response fixtures -- `MockInfrastructureTest.cs` — base for tests needing mock HTTP -- `Utilities.cs` — test utility helpers - -### RequestLoggingPlugin (integration) - -`CreateClient()` on `IntegrationTestBase` adds `RequestLoggingPlugin`, which logs HTTP requests and responses via `ITestOutputHelper`. No extra setup required. - -Custom plugins for a test: - -```csharp -var client = CreateClient(); -client.Plugins.Add(new MyTestPlugin()); -``` - -### Test coverage guidelines - -- Unit test: every new public `Query` method (operator or URL param) -- Unit test: null/invalid input → expected exception type -- Integration test: happy path with real API response -- Integration test: verify response shape (`Items`, `Count`, fields) -- Place integration tests under the folder that matches the feature area - -### Integration test file conventions - -- Folders mirror features: `Integration/QueryTests/`, `Integration/EntryTests/`, `Integration/GlobalFieldsTests/`, etc. -- One test class per broad concern when it makes sense; file names often end in `Test.cs` / `Tests.cs` -- Use `LogArrange` / `LogAct` / `LogAssert` / `LogContext` from `IntegrationTestBase` (see templates above) - -#### xUnit traits (examples) - -```csharp -[Trait("Category", "RetryIntegration")] -[Trait("Category", "LivePreview")] -[Trait("Category", "Sync")] -``` - -#### DisplayName convention - -```csharp -[Fact(DisplayName = "Query Operations - Regex Complex Pattern Matches Correctly")] -// FeatureArea - ComponentAction Outcome -``` diff --git a/snyk.json b/snyk.json deleted file mode 100644 index 4093b1ad..00000000 --- a/snyk.json +++ /dev/null @@ -1,434 +0,0 @@ -[ - { - "vulnerabilities": [], - "ok": true, - "dependencyCount": 18, - "org": "contentstack-devex", - "policy": "# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.\nversion: v1.25.1\nignore: {}\npatch: {}\n", - "isPrivate": true, - "licensesPolicy": { - "severities": {}, - "orgLicenseRules": { - "AGPL-1.0": { - "licenseType": "AGPL-1.0", - "severity": "high", - "instructions": "" - }, - "AGPL-3.0": { - "licenseType": "AGPL-3.0", - "severity": "high", - "instructions": "" - }, - "Artistic-1.0": { - "licenseType": "Artistic-1.0", - "severity": "medium", - "instructions": "" - }, - "Artistic-2.0": { - "licenseType": "Artistic-2.0", - "severity": "medium", - "instructions": "" - }, - "CDDL-1.0": { - "licenseType": "CDDL-1.0", - "severity": "medium", - "instructions": "" - }, - "CPOL-1.02": { - "licenseType": "CPOL-1.02", - "severity": "high", - "instructions": "" - }, - "EPL-1.0": { - "licenseType": "EPL-1.0", - "severity": "medium", - "instructions": "" - }, - "GPL-2.0": { - "licenseType": "GPL-2.0", - "severity": "high", - "instructions": "" - }, - "GPL-3.0": { - "licenseType": "GPL-3.0", - "severity": "high", - "instructions": "" - }, - "LGPL-2.0": { - "licenseType": "LGPL-2.0", - "severity": "medium", - "instructions": "" - }, - "LGPL-2.1": { - "licenseType": "LGPL-2.1", - "severity": "medium", - "instructions": "" - }, - "LGPL-3.0": { - "licenseType": "LGPL-3.0", - "severity": "medium", - "instructions": "" - }, - "MPL-1.1": { - "licenseType": "MPL-1.1", - "severity": "medium", - "instructions": "" - }, - "MPL-2.0": { - "licenseType": "MPL-2.0", - "severity": "medium", - "instructions": "" - }, - "MS-RL": { - "licenseType": "MS-RL", - "severity": "medium", - "instructions": "" - }, - "SimPL-2.0": { - "licenseType": "SimPL-2.0", - "severity": "high", - "instructions": "" - } - } - }, - "packageManager": "nuget", - "ignoreSettings": { - "adminOnly": false, - "reasonRequired": false, - "disregardFilesystemIgnores": false - }, - "summary": "No known vulnerabilities", - "filesystemPolicy": false, - "uniqueCount": 0, - "targetFile": "Contentstack.AspNetCore/obj/project.assets.json", - "projectName": "contentstack-dotnet", - "foundProjectCount": 4, - "displayTargetFile": "Contentstack.AspNetCore/obj/project.assets.json", - "hasUnknownVersions": false, - "path": "/Users/om.pawar/Desktop/SDKs/contentstack-dotnet" - }, - { - "vulnerabilities": [], - "ok": true, - "dependencyCount": 103, - "org": "contentstack-devex", - "policy": "# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.\nversion: v1.25.1\nignore: {}\npatch: {}\n", - "isPrivate": true, - "licensesPolicy": { - "severities": {}, - "orgLicenseRules": { - "AGPL-1.0": { - "licenseType": "AGPL-1.0", - "severity": "high", - "instructions": "" - }, - "AGPL-3.0": { - "licenseType": "AGPL-3.0", - "severity": "high", - "instructions": "" - }, - "Artistic-1.0": { - "licenseType": "Artistic-1.0", - "severity": "medium", - "instructions": "" - }, - "Artistic-2.0": { - "licenseType": "Artistic-2.0", - "severity": "medium", - "instructions": "" - }, - "CDDL-1.0": { - "licenseType": "CDDL-1.0", - "severity": "medium", - "instructions": "" - }, - "CPOL-1.02": { - "licenseType": "CPOL-1.02", - "severity": "high", - "instructions": "" - }, - "EPL-1.0": { - "licenseType": "EPL-1.0", - "severity": "medium", - "instructions": "" - }, - "GPL-2.0": { - "licenseType": "GPL-2.0", - "severity": "high", - "instructions": "" - }, - "GPL-3.0": { - "licenseType": "GPL-3.0", - "severity": "high", - "instructions": "" - }, - "LGPL-2.0": { - "licenseType": "LGPL-2.0", - "severity": "medium", - "instructions": "" - }, - "LGPL-2.1": { - "licenseType": "LGPL-2.1", - "severity": "medium", - "instructions": "" - }, - "LGPL-3.0": { - "licenseType": "LGPL-3.0", - "severity": "medium", - "instructions": "" - }, - "MPL-1.1": { - "licenseType": "MPL-1.1", - "severity": "medium", - "instructions": "" - }, - "MPL-2.0": { - "licenseType": "MPL-2.0", - "severity": "medium", - "instructions": "" - }, - "MS-RL": { - "licenseType": "MS-RL", - "severity": "medium", - "instructions": "" - }, - "SimPL-2.0": { - "licenseType": "SimPL-2.0", - "severity": "high", - "instructions": "" - } - } - }, - "packageManager": "nuget", - "ignoreSettings": { - "adminOnly": false, - "reasonRequired": false, - "disregardFilesystemIgnores": false - }, - "summary": "No known vulnerabilities", - "filesystemPolicy": false, - "uniqueCount": 0, - "targetFile": "Contentstack.Core.Tests/obj/project.assets.json", - "projectName": "contentstack-dotnet", - "foundProjectCount": 4, - "displayTargetFile": "Contentstack.Core.Tests/obj/project.assets.json", - "hasUnknownVersions": false, - "path": "/Users/om.pawar/Desktop/SDKs/contentstack-dotnet" - }, - { - "vulnerabilities": [], - "ok": true, - "dependencyCount": 103, - "org": "contentstack-devex", - "policy": "# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.\nversion: v1.25.1\nignore: {}\npatch: {}\n", - "isPrivate": true, - "licensesPolicy": { - "severities": {}, - "orgLicenseRules": { - "AGPL-1.0": { - "licenseType": "AGPL-1.0", - "severity": "high", - "instructions": "" - }, - "AGPL-3.0": { - "licenseType": "AGPL-3.0", - "severity": "high", - "instructions": "" - }, - "Artistic-1.0": { - "licenseType": "Artistic-1.0", - "severity": "medium", - "instructions": "" - }, - "Artistic-2.0": { - "licenseType": "Artistic-2.0", - "severity": "medium", - "instructions": "" - }, - "CDDL-1.0": { - "licenseType": "CDDL-1.0", - "severity": "medium", - "instructions": "" - }, - "CPOL-1.02": { - "licenseType": "CPOL-1.02", - "severity": "high", - "instructions": "" - }, - "EPL-1.0": { - "licenseType": "EPL-1.0", - "severity": "medium", - "instructions": "" - }, - "GPL-2.0": { - "licenseType": "GPL-2.0", - "severity": "high", - "instructions": "" - }, - "GPL-3.0": { - "licenseType": "GPL-3.0", - "severity": "high", - "instructions": "" - }, - "LGPL-2.0": { - "licenseType": "LGPL-2.0", - "severity": "medium", - "instructions": "" - }, - "LGPL-2.1": { - "licenseType": "LGPL-2.1", - "severity": "medium", - "instructions": "" - }, - "LGPL-3.0": { - "licenseType": "LGPL-3.0", - "severity": "medium", - "instructions": "" - }, - "MPL-1.1": { - "licenseType": "MPL-1.1", - "severity": "medium", - "instructions": "" - }, - "MPL-2.0": { - "licenseType": "MPL-2.0", - "severity": "medium", - "instructions": "" - }, - "MS-RL": { - "licenseType": "MS-RL", - "severity": "medium", - "instructions": "" - }, - "SimPL-2.0": { - "licenseType": "SimPL-2.0", - "severity": "high", - "instructions": "" - } - } - }, - "packageManager": "nuget", - "ignoreSettings": { - "adminOnly": false, - "reasonRequired": false, - "disregardFilesystemIgnores": false - }, - "summary": "No known vulnerabilities", - "filesystemPolicy": false, - "uniqueCount": 0, - "targetFile": "Contentstack.Core.Unit.Tests/obj/project.assets.json", - "projectName": "contentstack-dotnet", - "foundProjectCount": 4, - "displayTargetFile": "Contentstack.Core.Unit.Tests/obj/project.assets.json", - "hasUnknownVersions": false, - "path": "/Users/om.pawar/Desktop/SDKs/contentstack-dotnet" - }, - { - "vulnerabilities": [], - "ok": true, - "dependencyCount": 14, - "org": "contentstack-devex", - "policy": "# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.\nversion: v1.25.1\nignore: {}\npatch: {}\n", - "isPrivate": true, - "licensesPolicy": { - "severities": {}, - "orgLicenseRules": { - "AGPL-1.0": { - "licenseType": "AGPL-1.0", - "severity": "high", - "instructions": "" - }, - "AGPL-3.0": { - "licenseType": "AGPL-3.0", - "severity": "high", - "instructions": "" - }, - "Artistic-1.0": { - "licenseType": "Artistic-1.0", - "severity": "medium", - "instructions": "" - }, - "Artistic-2.0": { - "licenseType": "Artistic-2.0", - "severity": "medium", - "instructions": "" - }, - "CDDL-1.0": { - "licenseType": "CDDL-1.0", - "severity": "medium", - "instructions": "" - }, - "CPOL-1.02": { - "licenseType": "CPOL-1.02", - "severity": "high", - "instructions": "" - }, - "EPL-1.0": { - "licenseType": "EPL-1.0", - "severity": "medium", - "instructions": "" - }, - "GPL-2.0": { - "licenseType": "GPL-2.0", - "severity": "high", - "instructions": "" - }, - "GPL-3.0": { - "licenseType": "GPL-3.0", - "severity": "high", - "instructions": "" - }, - "LGPL-2.0": { - "licenseType": "LGPL-2.0", - "severity": "medium", - "instructions": "" - }, - "LGPL-2.1": { - "licenseType": "LGPL-2.1", - "severity": "medium", - "instructions": "" - }, - "LGPL-3.0": { - "licenseType": "LGPL-3.0", - "severity": "medium", - "instructions": "" - }, - "MPL-1.1": { - "licenseType": "MPL-1.1", - "severity": "medium", - "instructions": "" - }, - "MPL-2.0": { - "licenseType": "MPL-2.0", - "severity": "medium", - "instructions": "" - }, - "MS-RL": { - "licenseType": "MS-RL", - "severity": "medium", - "instructions": "" - }, - "SimPL-2.0": { - "licenseType": "SimPL-2.0", - "severity": "high", - "instructions": "" - } - } - }, - "packageManager": "nuget", - "ignoreSettings": { - "adminOnly": false, - "reasonRequired": false, - "disregardFilesystemIgnores": false - }, - "summary": "No known vulnerabilities", - "filesystemPolicy": false, - "uniqueCount": 0, - "targetFile": "Contentstack.Core/obj/project.assets.json", - "projectName": "contentstack-dotnet", - "foundProjectCount": 4, - "displayTargetFile": "Contentstack.Core/obj/project.assets.json", - "hasUnknownVersions": false, - "path": "/Users/om.pawar/Desktop/SDKs/contentstack-dotnet" - } -] From 19f30054437c3119af3d99d3630415f9b65ce384 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Sat, 20 Jun 2026 10:19:54 +0530 Subject: [PATCH 09/11] feat: added test cases fix --- .../ConfigurationTests/RegionSupportTest.cs | 3 +- .../ConfigUnitTests.cs | 52 ++++++++----------- .../ContentstackRegionUnitTests.cs | 3 +- Contentstack.Core/Models/Asset.cs | 2 +- Directory.Build.props | 2 +- Scripts/refresh-region.py | 4 +- build/contentstack.csharp.targets | 6 --- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/Contentstack.Core.Tests/Integration/ConfigurationTests/RegionSupportTest.cs b/Contentstack.Core.Tests/Integration/ConfigurationTests/RegionSupportTest.cs index 166662b5..8e8e8182 100644 --- a/Contentstack.Core.Tests/Integration/ConfigurationTests/RegionSupportTest.cs +++ b/Contentstack.Core.Tests/Integration/ConfigurationTests/RegionSupportTest.cs @@ -296,13 +296,14 @@ public void Region_AllValues_AreDefined() LogArrange("Setting up test"); var regions = Enum.GetValues(); - TestAssert.Equal(6, regions.Length); + TestAssert.Equal(7, regions.Length); TestAssert.Contains(ContentstackRegion.US, regions); TestAssert.Contains(ContentstackRegion.EU, regions); TestAssert.Contains(ContentstackRegion.AZURE_EU, regions); TestAssert.Contains(ContentstackRegion.AZURE_NA, regions); TestAssert.Contains(ContentstackRegion.GCP_NA, regions); TestAssert.Contains(ContentstackRegion.AU, regions); + TestAssert.Contains(ContentstackRegion.GCP_EU, regions); } [Fact(DisplayName = "Region Configuration - Region Options Default Value Is US")] diff --git a/Contentstack.Core.Unit.Tests/ConfigUnitTests.cs b/Contentstack.Core.Unit.Tests/ConfigUnitTests.cs index fe0b332b..075f65a8 100644 --- a/Contentstack.Core.Unit.Tests/ConfigUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ConfigUnitTests.cs @@ -84,9 +84,8 @@ public void Host_Get_ReturnsHostURL() // Act var result = config.Host; - // Assert - Assert.NotNull(result); - Assert.Contains("cdn.contentstack", result); + // Assert — Host is a custom override; defaults to empty string. URL resolution happens via BaseUrl. + Assert.Equal(string.Empty, result); } [Fact] @@ -274,12 +273,11 @@ public void HostURL_WithUSRegion_ReturnsIO() var config = CreateConfig(); config.Region = ContentstackRegion.US; - // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + // Act — HostURL was removed; BaseUrl is the public observable equivalent + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.io", result); + Assert.Contains("cdn.contentstack.io", result); } [Fact] @@ -290,11 +288,10 @@ public void HostURL_WithEURegion_ReturnsCOM() config.Region = ContentstackRegion.EU; // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.com", result); + Assert.Contains("eu-cdn.contentstack.com", result); } [Fact] @@ -305,11 +302,10 @@ public void HostURL_WithAzureEURegion_ReturnsCOM() config.Region = ContentstackRegion.AZURE_EU; // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.com", result); + Assert.Contains("azure-eu-cdn.contentstack.com", result); } [Fact] @@ -320,11 +316,10 @@ public void HostURL_WithAzureNARegion_ReturnsCOM() config.Region = ContentstackRegion.AZURE_NA; // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.com", result); + Assert.Contains("azure-na-cdn.contentstack.com", result); } [Fact] @@ -335,11 +330,10 @@ public void HostURL_WithGCPNARegion_ReturnsCOM() config.Region = ContentstackRegion.GCP_NA; // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.com", result); + Assert.Contains("gcp-na-cdn.contentstack.com", result); } [Fact] @@ -350,11 +344,10 @@ public void HostURL_WithAURegion_ReturnsCOM() config.Region = ContentstackRegion.AU; // Act - var hostUrlProperty = typeof(Config).GetProperty("HostURL", BindingFlags.NonPublic | BindingFlags.Instance); - var result = hostUrlProperty?.GetValue(config) as string; + var result = config.BaseUrl; // Assert - Assert.Equal("cdn.contentstack.com", result); + Assert.Contains("au-cdn.contentstack.com", result); } [Fact] @@ -364,12 +357,12 @@ public void RegionCode_WithUSRegion_ReturnsEmpty() var config = CreateConfig(); config.Region = ContentstackRegion.US; - // Act - var regionCodeMethod = typeof(Config).GetMethod("regionCode", BindingFlags.NonPublic | BindingFlags.Instance); - var result = regionCodeMethod?.Invoke(config, null) as string; + // Act — regionCode() was removed; US resolves to cdn.contentstack.io with no region prefix in the host + var result = config.BaseUrl; // Assert - Assert.Equal(string.Empty, result); + Assert.Contains("cdn.contentstack.io", result); + Assert.DoesNotContain("-cdn", result); } [Fact] @@ -379,13 +372,12 @@ public void RegionCode_WithEURegion_ReturnsFormattedCode() var config = CreateConfig(); config.Region = ContentstackRegion.EU; - // Act - var regionCodeMethod = typeof(Config).GetMethod("regionCode", BindingFlags.NonPublic | BindingFlags.Instance); - var result = regionCodeMethod?.Invoke(config, null) as string; + // Act — non-US regions resolve to a host with a region prefix (e.g. eu-cdn) + var result = config.BaseUrl; // Assert Assert.NotNull(result); - Assert.Contains("-", result); + Assert.Contains("-cdn", result); } [Fact] diff --git a/Contentstack.Core.Unit.Tests/ContentstackRegionUnitTests.cs b/Contentstack.Core.Unit.Tests/ContentstackRegionUnitTests.cs index 70dcc54d..f6c4fe2a 100644 --- a/Contentstack.Core.Unit.Tests/ContentstackRegionUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ContentstackRegionUnitTests.cs @@ -184,7 +184,7 @@ public void ContentstackRegion_GetValues_ReturnsAllRegions() // Assert Assert.NotNull(values); - Assert.Equal(6, values.Length); + Assert.Equal(7, values.Length); var regionArray = values.Cast().ToArray(); Assert.Contains(ContentstackRegion.US, regionArray); Assert.Contains(ContentstackRegion.EU, regionArray); @@ -192,6 +192,7 @@ public void ContentstackRegion_GetValues_ReturnsAllRegions() Assert.Contains(ContentstackRegion.AZURE_NA, regionArray); Assert.Contains(ContentstackRegion.GCP_NA, regionArray); Assert.Contains(ContentstackRegion.AU, regionArray); + Assert.Contains(ContentstackRegion.GCP_EU, regionArray); } #endregion diff --git a/Contentstack.Core/Models/Asset.cs b/Contentstack.Core/Models/Asset.cs index 64e48972..713db74b 100644 --- a/Contentstack.Core/Models/Asset.cs +++ b/Contentstack.Core/Models/Asset.cs @@ -166,7 +166,7 @@ public string Url /// The Version of Asset /// [JsonPropertyName("_version")] - public string Version { get; set; } + public int Version { get; set; } /// /// Dimension Object of the asset containing Height and width diff --git a/Directory.Build.props b/Directory.Build.props index 1f737f6c..e41df74e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 3.0.0 + 3.0.0-beta.2 diff --git a/Scripts/refresh-region.py b/Scripts/refresh-region.py index bc3db75b..b4a5fc3c 100644 --- a/Scripts/refresh-region.py +++ b/Scripts/refresh-region.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 # Refresh regions.json from the Contentstack CDN. # Usage (run from your project root): -# python3 Scripts/refresh-region.py # Mac / Linux -# python Scripts/refresh-region.py # Windows +# python3 Scripts/refresh-region.py +# python Scripts/refresh-region.py import glob import json diff --git a/build/contentstack.csharp.targets b/build/contentstack.csharp.targets index 52a70638..94d5abbf 100644 --- a/build/contentstack.csharp.targets +++ b/build/contentstack.csharp.targets @@ -1,10 +1,4 @@ - <_RefreshScriptDest>$(MSBuildProjectDirectory)/Scripts/refresh-region.py <_RefreshScriptSrc>$(MSBuildThisFileDirectory)../contentFiles/cs/any/Scripts/refresh-region.py From c50230fe9920cf2d5a02e69c7ce4a5afdadb5d16 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Sat, 20 Jun 2026 18:17:34 +0530 Subject: [PATCH 10/11] Delete requirements.txt --- Scripts/requirements.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 Scripts/requirements.txt diff --git a/Scripts/requirements.txt b/Scripts/requirements.txt deleted file mode 100644 index 377b0d3e..00000000 --- a/Scripts/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Secure XML parsing (fixes Snyk CWE-611 Insecure Xml Parser / XXE) -defusedxml>=0.7.1 From 10e71e6a6c0315f650ef6c09b7ec06f218d7cafe Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 22 Jun 2026 16:03:24 +0530 Subject: [PATCH 11/11] Update Contentstack.Core.csproj --- Contentstack.Core/Contentstack.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Contentstack.Core/Contentstack.Core.csproj b/Contentstack.Core/Contentstack.Core.csproj index db1751b2..d1270479 100644 --- a/Contentstack.Core/Contentstack.Core.csproj +++ b/Contentstack.Core/Contentstack.Core.csproj @@ -32,7 +32,7 @@ - +