diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 00000000..f5c1f870 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,5 @@ +# 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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d8381ca1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# 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 62e03361..00590d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +### Version: 2.27.0 +#### Date: Apr-23-2026 + +##### Feat: +- Timeline Preview Support + - Added `ReleaseId` and `PreviewTimestamp` properties to `LivePreviewConfig` for temporal content queries + - Enhanced `LivePreviewQueryAsync()` to support `preview_timestamp` and `release_id` parameters + - Implemented Timeline Preview API headers (`preview_timestamp`, `release_id`) in preview requests + - Added intelligent cache fingerprinting system to prevent stale timeline data + - New `IsCachedPreviewForCurrentQuery()` method for Timeline-aware cache validation + - Fork isolation now maintains independent Timeline contexts for concurrent operations + - Timeline Preview works seamlessly with complex nested content types and group fields +- Integration Test Coverage Enhancement + - Added comprehensive Timeline Preview integration test suites (70+ test cases) + - New test categories: `TimelinePreviewApiTests`, `TimelineAuthenticationTests`, `TimelineCacheValidationTests` + - Enhanced performance testing with Timeline-specific benchmarking + - Added authentication flow validation for Management Token vs Preview Token scenarios + - Comprehensive error handling tests for Timeline Preview edge cases + + ### Version: 2.26.0 #### Date: Feb-10-2026 diff --git a/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs b/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs new file mode 100644 index 00000000..6dd85446 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/ContentstackClientForkTests.cs @@ -0,0 +1,370 @@ +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 new file mode 100644 index 00000000..0c60767a --- /dev/null +++ b/Contentstack.Core.Unit.Tests/ContentstackClientResetTests.cs @@ -0,0 +1,476 @@ +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 44d9cb9a..8495bf66 100644 --- a/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/ContentstackClientUnitTests.cs @@ -8,31 +8,24 @@ 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 { /// - /// Unit tests for ContentstackClient class - uses mocks and AutoFixture, no real API calls - /// Follows Management SDK pattern + /// Comprehensive unit tests for ContentstackClient class + /// Includes Timeline Preview functionality: Fork(), ResetLivePreview(), LivePreviewQueryAsync() + /// Uses mocks and AutoFixture, no real API calls /// - public class ContentstackClientUnitTests + [Trait("Category", "TimelinePreview")] + [Trait("Category", "Unit")] + [Trait("Category", "Fast")] + public class ContentstackClientUnitTests : ContentstackClientTestBase { - 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() @@ -956,11 +949,1445 @@ public void LivePreviewQueryAsync_ClearsLivePreviewConfig() task.Wait(); // Assert - Assert.Null(client.LivePreviewConfig.LivePreview); - Assert.Null(client.LivePreviewConfig.PreviewTimestamp); - Assert.Null(client.LivePreviewConfig.ReleaseId); + 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); } + [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 new file mode 100644 index 00000000..6ec0d172 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/Helpers/ContentstackClientTestBase.cs @@ -0,0 +1,424 @@ +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 new file mode 100644 index 00000000..1d1a2e79 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/Helpers/TimelinePerformanceHelpers.cs @@ -0,0 +1,164 @@ +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 new file mode 100644 index 00000000..48c9cbe4 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/Helpers/TimelineTestDataBuilder.cs @@ -0,0 +1,310 @@ +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 new file mode 100644 index 00000000..9f7c3363 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/LivePreviewConfigFingerprintTests.cs @@ -0,0 +1,531 @@ +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 37e22d76..bd6a7b88 100644 --- a/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/LivePreviewConfigUnitTests.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using AutoFixture; using Contentstack.Core.Configuration; using Newtonsoft.Json.Linq; @@ -152,6 +153,309 @@ 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 new file mode 100644 index 00000000..b5790591 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/Mokes/TimelineMockHttpHandler.cs @@ -0,0 +1,259 @@ +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 new file mode 100644 index 00000000..8b030c48 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/TimelineCacheBehaviorTests.cs @@ -0,0 +1,561 @@ +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 new file mode 100644 index 00000000..235d6976 --- /dev/null +++ b/Contentstack.Core.Unit.Tests/TimelineIsolationTests.cs @@ -0,0 +1,540 @@ +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 new file mode 100644 index 00000000..5d08b65b --- /dev/null +++ b/Contentstack.Core.Unit.Tests/TimelinePreviewErrorHandlingTests.cs @@ -0,0 +1,604 @@ +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 18670ca5..07c5310d 100644 --- a/Contentstack.Core/Configuration/LivePreviewConfig.cs +++ b/Contentstack.Core/Configuration/LivePreviewConfig.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json.Linq; +using System; +using Newtonsoft.Json.Linq; namespace Contentstack.Core.Configuration { @@ -12,7 +13,24 @@ public class LivePreviewConfig 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; } + 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 85ebccdb..472a0565 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,6 +61,100 @@ 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. /// @@ -115,7 +209,7 @@ public ContentstackClient(IOptions options) this.SetConfig(cnfig); if (_options.LivePreview != null) { - this.LivePreviewConfig = _options.LivePreview; + this.LivePreviewConfig = CloneLivePreviewConfig(_options.LivePreview); } else { @@ -341,6 +435,11 @@ 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() { @@ -386,11 +485,25 @@ private async Task GetLivePreviewData() try { HttpRequestHandler RequestHandler = new HttpRequestHandler(this); - //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); + string basePreview = this.Config.getLivePreviewUrl(this.LivePreviewConfig); + string URL = String.Format("{0}/content_types/{1}/entries/{2}", basePreview, 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"]; + 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; } catch (Exception ex) { @@ -612,6 +725,10 @@ 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; @@ -655,10 +772,28 @@ public async Task LivePreviewQueryAsync(Dictionary query) query.TryGetValue("preview_timestamp", out PreviewTimestamp); this.LivePreviewConfig.PreviewTimestamp = PreviewTimestamp; } - //if (!string.IsNullOrEmpty(this.LivePreviewConfig.LivePreview)) - //{ - // this.LivePreviewConfig.PreviewResponse = await GetLivePreviewData(); - //} + + 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. + } + } } /// diff --git a/Contentstack.Core/Models/Entry.cs b/Contentstack.Core/Models/Entry.cs index d683ecdb..70d1948d 100644 --- a/Contentstack.Core/Models/Entry.cs +++ b/Contentstack.Core/Models/Entry.cs @@ -1401,13 +1401,47 @@ 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.StackInstance.LivePreviewConfig.Enable == true - && this.ContentTypeInstance.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId - && header.Key == "access_token" && !string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)) + if (this.ContentTypeInstance != null + && livePreviewConfig != null + && livePreviewConfig.Enable + && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId + && header.Key == "access_token" + && !string.IsNullOrEmpty(livePreviewConfig.LivePreview)) { continue; } @@ -1415,25 +1449,36 @@ public async Task Fetch() } } bool isLivePreview = false; - if (this.ContentTypeInstance.StackInstance.LivePreviewConfig.Enable == true && this.ContentTypeInstance.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance.ContentTypeId) + 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(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)? "init" : this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview); + mainJson.Add("live_preview", string.IsNullOrEmpty(livePreviewConfig.LivePreview)? "init" : 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; + if (!string.IsNullOrEmpty(livePreviewConfig.ManagementToken)) { + headerAll["authorization"] = livePreviewConfig.ManagementToken; + } else if (!string.IsNullOrEmpty(livePreviewConfig.PreviewToken)) { + headerAll["preview_token"] = livePreviewConfig.PreviewToken; } else { throw new LivePreviewException(); } - if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId)) + if (!string.IsNullOrEmpty(livePreviewConfig.ReleaseId)) { - headerAll["release_id"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId; + headerAll["release_id"] = livePreviewConfig.ReleaseId; } - if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp)) + if (!string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp)) { - headerAll["preview_timestamp"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp; + headerAll["preview_timestamp"] = livePreviewConfig.PreviewTimestamp; } isLivePreview = true; diff --git a/Contentstack.Core/Models/Query.cs b/Contentstack.Core/Models/Query.cs index c0d6ead1..09dcc9c8 100644 --- a/Contentstack.Core/Models/Query.cs +++ b/Contentstack.Core/Models/Query.cs @@ -1874,26 +1874,37 @@ private async Task Exec() Dictionary mainJson = new Dictionary(); bool isLivePreview = false; - 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; + 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; } else { throw new LivePreviewException(); } - if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId)) + if (!string.IsNullOrEmpty(livePreviewConfig.ReleaseId)) { - headerAll["release_id"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.ReleaseId; + headerAll["release_id"] = livePreviewConfig.ReleaseId; } - if (!string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp)) + if (!string.IsNullOrEmpty(livePreviewConfig.PreviewTimestamp)) { - headerAll["preview_timestamp"] = this.ContentTypeInstance.StackInstance.LivePreviewConfig.PreviewTimestamp; + headerAll["preview_timestamp"] = livePreviewConfig.PreviewTimestamp; } isLivePreview = true; @@ -1903,10 +1914,11 @@ private async Task Exec() { foreach (var header in headers) { - if (this.ContentTypeInstance!=null && this.ContentTypeInstance?.StackInstance.LivePreviewConfig.Enable == true - && this.ContentTypeInstance?.StackInstance.LivePreviewConfig.ContentTypeUID == this.ContentTypeInstance?.ContentTypeId + if (this.ContentTypeInstance!=null && livePreviewConfig != null + && livePreviewConfig.Enable + && livePreviewConfig.ContentTypeUID == this.ContentTypeInstance?.ContentTypeId && header.Key == "access_token" - && !string.IsNullOrEmpty(this.ContentTypeInstance.StackInstance.LivePreviewConfig.LivePreview)) + && !string.IsNullOrEmpty(livePreviewConfig.LivePreview)) { continue; } diff --git a/Directory.Build.props b/Directory.Build.props index 0780faed..b5bbd620 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 2.26.0 + 2.27.0 diff --git a/README.md b/README.md index ea063835..a07ff77c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ .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 new file mode 100644 index 00000000..f3e91cdd --- /dev/null +++ b/skills/README.md @@ -0,0 +1,21 @@ +# 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 new file mode 100644 index 00000000..9ea79041 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,223 @@ +--- +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 new file mode 100644 index 00000000..06f5ac70 --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,90 @@ +--- +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 policy + +[`.github/workflows/check-branch.yml`](../../../.github/workflows/check-branch.yml): on `pull_request`, if base is `master` and head is not `staging`, the job fails with a message to open PRs from `staging` toward `master` per team policy. + +### 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 new file mode 100644 index 00000000..b4b08a52 --- /dev/null +++ b/skills/error-handling/SKILL.md @@ -0,0 +1,298 @@ +--- +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 new file mode 100644 index 00000000..c7a49100 --- /dev/null +++ b/skills/models-and-serialization/SKILL.md @@ -0,0 +1,248 @@ +--- +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 new file mode 100644 index 00000000..fa9bbd80 --- /dev/null +++ b/skills/query-building/SKILL.md @@ -0,0 +1,312 @@ +--- +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 new file mode 100644 index 00000000..b6e36e3a --- /dev/null +++ b/skills/sdk-core-patterns/SKILL.md @@ -0,0 +1,241 @@ +--- +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 new file mode 100644 index 00000000..e76b7bce --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,334 @@ +--- +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 new file mode 100644 index 00000000..4093b1ad --- /dev/null +++ b/snyk.json @@ -0,0 +1,434 @@ +[ + { + "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" + } +]