diff --git a/.github/workflows/back-merge-pr.yml b/.github/workflows/back-merge-pr.yml new file mode 100644 index 0000000..e9ac740 --- /dev/null +++ b/.github/workflows/back-merge-pr.yml @@ -0,0 +1,56 @@ +# Opens a PR from main → development after changes land on main (back-merge). + +name: Back-merge main to development + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + open-back-merge-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Open back-merge PR if needed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git fetch origin development main + + MAIN_SHA=$(git rev-parse origin/main) + DEV_SHA=$(git rev-parse origin/development) + + if [ "$MAIN_SHA" = "$DEV_SHA" ]; then + echo "main and development are at the same commit; nothing to back-merge." + exit 0 + fi + + EXISTING=$(gh pr list --repo "${{ github.repository }}" \ + --base development \ + --head main \ + --state open \ + --json number \ + --jq 'length') + + if [ "$EXISTING" -gt 0 ]; then + echo "An open PR from main to development already exists; skipping." + exit 0 + fi + + gh pr create --repo "${{ github.repository }}" \ + --base development \ + --head main \ + --title "chore: back-merge main into development" \ + --body "Automated back-merge after changes landed on \`main\`. Review and merge to keep \`development\` in sync." + + echo "Created back-merge PR main → development." diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml deleted file mode 100644 index 00a6a8a..0000000 --- a/.github/workflows/check-branch.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Check Branch' - -on: - pull_request: - -jobs: - check_branch: - runs-on: ubuntu-latest - steps: - - name: Comment PR - if: github.base_ref == 'main' && github.head_ref != 'staging' - uses: thollander/actions-comment-pull-request@v2 - with: - message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - - name: Check branch - if: github.base_ref == 'main' && github.head_ref != 'staging' - run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." - exit 1 diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 0000000..658d336 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,117 @@ +# Release-affecting changes under Contentstack.Management*/ or Directory.Build.props / core csproj +# require Directory.Build.props Version + CHANGELOG.md updates vs latest tag. + +name: Check Version Bump + +on: + pull_request: + +jobs: + version-bump: + name: Version & changelog bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed files + id: detect + run: | + FILES=$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}") + echo "Changed files:" + echo "$FILES" + + CODE_CHANGED=false + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + Contentstack.Management.Core.Unit.Tests/*|Contentstack.Management.Core.Tests/*) + continue + ;; + esac + if [[ "$f" == "Directory.Build.props" ]] || \ + [[ "$f" == Contentstack.Management.Core/* ]] || \ + [[ "$f" == Contentstack.Management.ASPNETCore/* ]]; then + CODE_CHANGED=true + break + fi + done <<< "$FILES" + + PROPS_CHANGED=false + CHANGELOG_CHANGED=false + echo "$FILES" | grep -qx 'Directory.Build.props' && PROPS_CHANGED=true + echo "$FILES" | grep -qx 'CHANGELOG.md' && CHANGELOG_CHANGED=true + + VERSION_FILES_OK=false + if [ "$PROPS_CHANGED" = true ] && [ "$CHANGELOG_CHANGED" = true ]; then + VERSION_FILES_OK=true + fi + + echo "code_changed=$CODE_CHANGED" >> "$GITHUB_OUTPUT" + echo "version_files_ok=$VERSION_FILES_OK" >> "$GITHUB_OUTPUT" + + - name: Skip when no release-affecting code changed + if: steps.detect.outputs.code_changed != 'true' + run: | + echo "No management SDK / version props changes. Skipping version-bump check." + exit 0 + + - name: Fail when version files were not both updated + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_ok != 'true' + run: | + echo "::error::Bump in Directory.Build.props and add a ## [vX.Y.Z] section at the top of CHANGELOG.md." + exit 1 + + - name: Validate version vs latest tag and changelog header + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_ok == 'true' + run: | + set -euo pipefail + PROJ_VERSION=$(python3 <<'PY' + import re + text = open("Directory.Build.props").read() + m = re.search(r"\s*([^<]+)\s*", text) + if not m: + raise SystemExit("Could not read from Directory.Build.props") + print(m.group(1).strip()) + PY + ) + + git fetch --tags --force 2>/dev/null || true + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found. Skipping semver vs tag check." + CHANGELOG_HEAD=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' CHANGELOG.md | head -1) + if [ -z "$CHANGELOG_HEAD" ]; then + echo "::error::Could not find a ## [vX.Y.Z] entry at the top of CHANGELOG.md." + exit 1 + fi + if [ "$CHANGELOG_HEAD" != "$PROJ_VERSION" ]; then + echo "::error::CHANGELOG top version ($CHANGELOG_HEAD) does not match Directory.Build.props Version ($PROJ_VERSION)." + exit 1 + fi + exit 0 + fi + + LATEST_VERSION="${LATEST_TAG#v}" + LATEST_VERSION="${LATEST_VERSION%%-*}" + if [ "$(printf '%s\n' "$LATEST_VERSION" "$PROJ_VERSION" | sort -V | tail -1)" != "$PROJ_VERSION" ]; then + echo "::error::Version ($PROJ_VERSION) must be greater than latest tag ($LATEST_TAG)." + exit 1 + fi + if [ "$PROJ_VERSION" = "$LATEST_VERSION" ]; then + echo "::error::Version ($PROJ_VERSION) must be strictly greater than latest tag version ($LATEST_VERSION)." + exit 1 + fi + + CHANGELOG_HEAD=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' CHANGELOG.md | head -1) + if [ -z "$CHANGELOG_HEAD" ]; then + echo "::error::Could not find a ## [vX.Y.Z] entry at the top of CHANGELOG.md." + exit 1 + fi + if [ "$CHANGELOG_HEAD" != "$PROJ_VERSION" ]; then + echo "::error::CHANGELOG top version ($CHANGELOG_HEAD) does not match Directory.Build.props Version ($PROJ_VERSION)." + exit 1 + fi + echo "Version bump check passed: Directory.Build.props and CHANGELOG at $PROJ_VERSION (latest tag: $LATEST_TAG)." diff --git a/AGENTS.md b/AGENTS.md index 1a8fcbe..1a45d88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ | **Test (integration)** | `dotnet test Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj` — requires local `appsettings.json` with credentials (see [`skills/testing/SKILL.md`](skills/testing/SKILL.md)). | | **Pack (release)** | `dotnet pack -c Release -o out` (as in [`.github/workflows/nuget-publish.yml`](.github/workflows/nuget-publish.yml)). | -**CI:** [`.github/workflows/unit-test.yml`](.github/workflows/unit-test.yml) (unit tests on PR/push). **Branches:** PRs normally target **`development`**; **`main`** is for **hotfixes**. PRs **into `main`** must come from **`staging`** per [`.github/workflows/check-branch.yml`](.github/workflows/check-branch.yml). +**CI:** [`.github/workflows/unit-test.yml`](.github/workflows/unit-test.yml) (unit tests on PR/push). **Branches:** feature work merges to **`development`**; **release PRs** are **`development` → `main`** (no `staging`). After `main` advances, [`.github/workflows/back-merge-pr.yml`](.github/workflows/back-merge-pr.yml) can open **`main` → `development`**. **Releases:** create a **GitHub Release** to run [`.github/workflows/nuget-publish.yml`](.github/workflows/nuget-publish.yml) (`release: created`). [`.github/workflows/check-version-bump.yml`](.github/workflows/check-version-bump.yml) enforces version + `CHANGELOG.md` on relevant PRs. ## Where the documentation lives: skills diff --git a/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj b/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj index 431fb77..a8efdf8 100644 --- a/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj +++ b/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj @@ -1,55 +1,55 @@ - - - - net7.0 - - false - $(Version) - - true - ../CSManagementSDK.snk - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive -all - - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - + + + + net7.0 + + false + $(Version) + + true + ../CSManagementSDK.snk + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive +all + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack022_VariantGroupTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack022_VariantGroupTest.cs new file mode 100644 index 0000000..fe7f07a --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack022_VariantGroupTest.cs @@ -0,0 +1,1229 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Models.Fields; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Contentstack.Management.Core.Queryable; +using Contentstack.Management.Core.Exceptions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack022_VariantGroupTest + { + private static ContentstackClient _client; + private Stack _stack; + private static string _testVariantGroupUid; + private static string _testContentTypeUid; + private static List _availableContentTypes = new List(); + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + try + { + _client = Contentstack.CreateAuthenticatedClient(); + } + catch (Exception ex) + { + Console.WriteLine($"Authentication failed: {ex.Message}. Tests may not run if API key is missing."); + _client = new ContentstackClient(); + } + } + + [ClassCleanup] + public static void ClassCleanup() + { + try { _client?.Logout(); } catch { } + _client = null; + } + + [TestInitialize] + public void TestInitialize() + { + // Read the API key from appSettings.json + string apiKey = Contentstack.Config["Contentstack:Stack:api_key"]; + + // Optional: Fallback to stackApiKey.txt if it's missing in appSettings.json + if (string.IsNullOrEmpty(apiKey)) + { + StackResponse response = StackResponse.getStack(_client.serializer); + apiKey = response.Stack.APIKey; + } + + _stack = _client.Stack(apiKey); + } + + #region Positive Test Cases + + [TestMethod] + [DoNotParallelize] + public async Task Test001_Should_Find_All_VariantGroups() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_FindAll_Positive"); + + var collection = new ParameterCollection(); + collection.Add("include_variant_info", "true"); + collection.Add("include_variant_count", "true"); + + var response = await _stack.VariantGroup().FindAsync(collection); + + Assert.IsTrue(response.IsSuccessStatusCode, + $"Should successfully fetch variant groups: {response.OpenResponse()}"); + + var jObject = response.OpenJObjectResponse(); + var variantGroups = jObject["variant_groups"] as JArray; + + Assert.IsNotNull(variantGroups, "Variant groups array should not be null"); + Console.WriteLine($"Found {variantGroups.Count} variant groups"); + + // Store first variant group for subsequent tests + if (variantGroups.Count > 0) + { + _testVariantGroupUid = variantGroups[0]["uid"]?.ToString(); + TestOutputLogger.LogContext("VariantGroupUID", _testVariantGroupUid); + Console.WriteLine($"Using variant group UID: {_testVariantGroupUid}"); + } + else + { + Console.WriteLine("Warning: No variant groups found. Some subsequent tests may be skipped."); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Find_VariantGroups_With_Pagination() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_FindWithPagination_Positive"); + + var collection = new ParameterCollection(); + collection.Add("limit", "5"); + collection.Add("skip", "0"); + collection.Add("include_count", "true"); + + var response = await _stack.VariantGroup().FindAsync(collection); + + Assert.IsTrue(response.IsSuccessStatusCode, + $"Should successfully fetch variant groups with pagination: {response.OpenResponse()}"); + + var jObject = response.OpenJObjectResponse(); + Assert.IsNotNull(jObject["variant_groups"], "Should have variant_groups array"); + + // Check if count is included when requested + if (jObject["count"] != null) + { + Console.WriteLine($"Total count: {jObject["count"]}"); + } + + var variantGroups = jObject["variant_groups"] as JArray; + Console.WriteLine($"Returned {variantGroups.Count} variant groups with pagination"); + + // Verify pagination worked - should return at most 5 items + Assert.IsTrue(variantGroups.Count <= 5, "Pagination limit should be respected"); + } + + [TestMethod] + [DoNotParallelize] + public async Task Test003_Should_Discover_Available_ContentTypes() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_DiscoverContentTypes_Positive"); + + // Query existing content types to find ones we can use for testing + var collection = new ParameterCollection(); + collection.Add("limit", "20"); + + var contentTypesResponse = await _stack.ContentType().Query().FindAsync(collection); + + if (!contentTypesResponse.IsSuccessStatusCode) + { + Assert.Inconclusive($"Could not fetch content types: {contentTypesResponse.OpenResponse()}"); + return; + } + + var jObject = contentTypesResponse.OpenJObjectResponse(); + var contentTypesArray = jObject["content_types"] as JArray; + + if (contentTypesArray == null || contentTypesArray.Count == 0) + { + Assert.Inconclusive("No content types found in the stack. Please create at least one content type to run VariantGroup link/unlink tests."); + return; + } + + // Store all available content types for use in various tests + foreach (var ct in contentTypesArray) + { + var uid = ct["uid"]?.ToString(); + if (!string.IsNullOrEmpty(uid)) + { + _availableContentTypes.Add(uid); + } + } + + // Use the first available content type as primary test subject + _testContentTypeUid = _availableContentTypes[0]; + var primaryContentTypeName = contentTypesArray[0]["title"]?.ToString(); + + Assert.IsNotNull(_testContentTypeUid, "Content type UID should not be null"); + + TestOutputLogger.LogContext("ContentTypeUID", _testContentTypeUid); + Console.WriteLine($"Using primary content type: {primaryContentTypeName} (UID: {_testContentTypeUid})"); + + // Log all available content types for debugging + Console.WriteLine($"Found {_availableContentTypes.Count} content types in the stack:"); + foreach (var ct in contentTypesArray) + { + Console.WriteLine($" - {ct["title"]} (UID: {ct["uid"]})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Successfully_Link_Single_ContentType() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met. Ensure Test001 and Test003 run first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_LinkSingleContentType_Positive"); + + var contentTypeUids = new List { _testContentTypeUid }; + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + if (linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Successfully linked content type {_testContentTypeUid} to variant group {_testVariantGroupUid}"); + + // Verify the response structure + var responseObj = linkResponse.OpenJObjectResponse(); + Assert.IsNotNull(responseObj, "Response should contain JSON object"); + } + else + { + Console.WriteLine($"⚠️ Link operation returned: {linkResponse.StatusCode} - {linkResponse.OpenResponse()}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Link operation failed due to API constraints: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Unexpected exception: {ex.Message}"); + throw; + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test005_Should_Successfully_Link_Multiple_ContentTypes() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || _availableContentTypes.Count < 2) + { + Assert.Inconclusive("Prerequisites not met. Need variant group and at least 2 content types."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_LinkMultipleContentTypes_Positive"); + + // Use up to 3 content types for batch linking test + var contentTypeUids = _availableContentTypes.Take(Math.Min(3, _availableContentTypes.Count)).ToList(); + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + if (linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Successfully linked {contentTypeUids.Count} content types to variant group {_testVariantGroupUid}"); + Console.WriteLine($" Content types: {string.Join(", ", contentTypeUids)}"); + } + else + { + Console.WriteLine($"⚠️ Batch link operation returned: {linkResponse.StatusCode} - {linkResponse.OpenResponse()}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Batch link operation failed: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Unexpected exception during batch link: {ex.Message}"); + throw; + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Successfully_Unlink_Single_ContentType() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met. Ensure Test001 and Test003 run first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_UnlinkSingleContentType_Positive"); + + var contentTypeUids = new List { _testContentTypeUid }; + + try + { + var unlinkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + if (unlinkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Successfully unlinked content type {_testContentTypeUid} from variant group {_testVariantGroupUid}"); + } + else + { + Console.WriteLine($"⚠️ Unlink operation returned: {unlinkResponse.StatusCode} - {unlinkResponse.OpenResponse()}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Unlink operation failed: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Unexpected exception during unlink: {ex.Message}"); + throw; + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test007_Should_Successfully_Unlink_Multiple_ContentTypes() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || _availableContentTypes.Count < 2) + { + Assert.Inconclusive("Prerequisites not met. Need variant group and multiple content types."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_UnlinkMultipleContentTypes_Positive"); + + var contentTypeUids = _availableContentTypes.Take(2).ToList(); + + try + { + var unlinkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + if (unlinkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Successfully unlinked {contentTypeUids.Count} content types from variant group"); + } + else + { + Console.WriteLine($"⚠️ Batch unlink operation returned: {unlinkResponse.StatusCode} - {unlinkResponse.OpenResponse()}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Batch unlink operation failed: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Work_With_Synchronous_Operations() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_SynchronousOperations_Positive"); + + // Test synchronous Find + var findResponse = _stack.VariantGroup().Find(); + Assert.IsTrue(findResponse.IsSuccessStatusCode, + $"Synchronous Find should work: {findResponse.OpenResponse()}"); + + Console.WriteLine("✅ Synchronous Find operation successful"); + + if (!string.IsNullOrEmpty(_testVariantGroupUid) && !string.IsNullOrEmpty(_testContentTypeUid)) + { + var contentTypeUids = new List { _testContentTypeUid }; + + // Test synchronous Link + try + { + var linkResponse = _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypes(contentTypeUids); + + if (linkResponse.IsSuccessStatusCode) + { + Console.WriteLine("✅ Synchronous Link operation succeeded"); + } + else + { + Console.WriteLine($"⚠️ Synchronous Link returned: {linkResponse.StatusCode}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Synchronous Link failed: {ex.ErrorMessage}"); + } + + // Test synchronous Unlink + try + { + var unlinkResponse = _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypes(contentTypeUids); + + if (unlinkResponse.IsSuccessStatusCode) + { + Console.WriteLine("✅ Synchronous Unlink operation succeeded"); + } + else + { + Console.WriteLine($"⚠️ Synchronous Unlink returned: {unlinkResponse.StatusCode}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"⚠️ Synchronous Unlink failed: {ex.ErrorMessage}"); + } + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Handle_Various_Query_Parameters() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_QueryParameters_Positive"); + + var collection = new ParameterCollection(); + collection.Add("include_variant_info", "true"); + collection.Add("include_variant_count", "true"); + collection.Add("include_count", "true"); + collection.Add("asc", "created_at"); + collection.Add("limit", "10"); + + var response = await _stack.VariantGroup().FindAsync(collection); + + Assert.IsTrue(response.IsSuccessStatusCode, + $"Should work with query parameters: {response.OpenResponse()}"); + + var jObject = response.OpenJObjectResponse(); + var variantGroups = jObject["variant_groups"] as JArray; + Assert.IsNotNull(variantGroups, "Should have variant_groups array with advanced parameters"); + + Console.WriteLine($"✅ Found {variantGroups.Count} variant groups with advanced query parameters"); + + if (jObject["count"] != null) + { + Console.WriteLine($" Total count in response: {jObject["count"]}"); + } + } + + #endregion + + #region Negative Test Cases + + [TestMethod] + [DoNotParallelize] + public async Task Test101_Should_Fail_With_Invalid_VariantGroup_UID() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_InvalidUID_Negative"); + + var invalidUid = "invalid_variant_group_uid_12345"; + var contentTypeUids = new List { _testContentTypeUid ?? "product_banner" }; + + try + { + var linkResponse = await _stack + .VariantGroup(invalidUid) + .LinkContentTypesAsync(contentTypeUids); + + if (!linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly failed with invalid variant group UID: {linkResponse.StatusCode}"); + Console.WriteLine($" Error response: {linkResponse.OpenResponse()}"); + } + else + { + Assert.Fail("Expected operation to fail with invalid variant group UID, but it succeeded"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ Correctly threw ContentstackErrorException: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + Assert.IsTrue(ex.ErrorCode > 0, "Error code should be set"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test102_Should_Fail_With_Empty_ContentType_List() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for negative testing."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_EmptyContentTypeList_Negative"); + + var emptyContentTypeList = new List(); + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(emptyContentTypeList); + + Assert.Fail("Expected ArgumentNullException for empty content type list, but operation succeeded"); + } + catch (ArgumentNullException ex) + { + Console.WriteLine($"✅ Correctly threw ArgumentNullException: {ex.Message}"); + Assert.IsTrue(ex.Message.Contains("contentTypeUids"), "Exception should mention contentTypeUids parameter"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API correctly rejected empty list: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test103_Should_Fail_With_Null_ContentType_List() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for negative testing."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_NullContentTypeList_Negative"); + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(null); + + Assert.Fail("Expected ArgumentNullException for null content type list, but operation succeeded"); + } + catch (ArgumentNullException ex) + { + Console.WriteLine($"✅ Correctly threw ArgumentNullException: {ex.Message}"); + Assert.IsTrue(ex.ParamName == "contentTypeUids", "Parameter name should be contentTypeUids"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test104_Should_Fail_With_Invalid_ContentType_UIDs() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for negative testing."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_InvalidContentTypeUIDs_Negative"); + + var invalidContentTypeUids = new List + { + "nonexistent_content_type_1", + "invalid_content_type_2", + "fake_content_type_uid_12345" + }; + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(invalidContentTypeUids); + + if (!linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly failed with invalid content type UIDs: {linkResponse.StatusCode}"); + Console.WriteLine($" Error response: {linkResponse.OpenResponse()}"); + } + else + { + Console.WriteLine("⚠️ Operation unexpectedly succeeded with invalid content type UIDs"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ Correctly threw ContentstackErrorException: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test105_Should_Fail_With_Mixed_Valid_Invalid_ContentTypes() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met for mixed validation test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_MixedValidInvalidContentTypes_Negative"); + + var mixedContentTypeUids = new List + { + _testContentTypeUid, // Valid + "nonexistent_content_type", // Invalid + "another_fake_uid" // Invalid + }; + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(mixedContentTypeUids); + + if (!linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly failed with mixed valid/invalid content types: {linkResponse.StatusCode}"); + + // Check if error response provides details about which content types failed + var errorResponse = linkResponse.OpenResponse(); + Console.WriteLine($" Error details: {errorResponse}"); + } + else + { + Console.WriteLine("⚠️ Operation unexpectedly succeeded with mixed valid/invalid content types"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ Correctly threw exception for mixed validation: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test106_Should_Fail_With_Empty_String_VariantGroup_UID() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_EmptyStringUID_Negative"); + + var contentTypeUids = new List { _testContentTypeUid ?? "product_banner" }; + + try + { + var linkResponse = await _stack + .VariantGroup("") + .LinkContentTypesAsync(contentTypeUids); + + Assert.Fail("Expected operation to fail with empty string UID, but it succeeded"); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"✅ Correctly threw InvalidOperationException for empty UID: {ex.Message}"); + Assert.IsTrue(ex.Message.Contains("Variant group UID is required"), "Exception should mention required UID"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API correctly rejected empty UID: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test107_Should_Fail_With_Null_VariantGroup_UID() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_NullUID_Negative"); + + var contentTypeUids = new List { _testContentTypeUid ?? "product_banner" }; + + try + { + var linkResponse = await _stack + .VariantGroup(null) + .LinkContentTypesAsync(contentTypeUids); + + Assert.Fail("Expected operation to fail with null UID, but it succeeded"); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"✅ Correctly threw InvalidOperationException for null UID: {ex.Message}"); + Assert.IsTrue(ex.Message.Contains("Variant group UID is required"), "Exception should mention required UID"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API correctly rejected null UID: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test108_Should_Handle_Duplicate_ContentType_UIDs() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met for duplicate UIDs test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_DuplicateContentTypeUIDs_Negative"); + + var duplicateContentTypeUids = new List + { + _testContentTypeUid, + _testContentTypeUid, // Duplicate + _testContentTypeUid // Another duplicate + }; + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(duplicateContentTypeUids); + + // This might succeed or fail depending on API behavior - both are acceptable + if (linkResponse.IsSuccessStatusCode) + { + Console.WriteLine("✅ API handled duplicate content type UIDs gracefully (deduplication)"); + } + else + { + Console.WriteLine($"✅ API rejected duplicate UIDs: {linkResponse.StatusCode}"); + Console.WriteLine($" Error response: {linkResponse.OpenResponse()}"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API rejected duplicate UIDs with exception: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test109_Should_Handle_Malformed_VariantGroup_UID() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_MalformedUID_Negative"); + + var malformedUids = new List + { + " ", // Whitespace only + "uid with spaces", // Contains spaces + "uid@with#special$chars", // Special characters + "UID_WITH_UPPER_CASE", // Uppercase (might be invalid format) + "uid-with-very-long-name-that-exceeds-normal-limits-and-might-be-rejected-by-api-validation" // Very long + }; + + var contentTypeUids = new List { _testContentTypeUid ?? "product_banner" }; + + foreach (var malformedUid in malformedUids) + { + try + { + var linkResponse = await _stack + .VariantGroup(malformedUid) + .LinkContentTypesAsync(contentTypeUids); + + if (!linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly rejected malformed UID '{malformedUid}': {linkResponse.StatusCode}"); + } + else + { + Console.WriteLine($"⚠️ Malformed UID '{malformedUid}' was unexpectedly accepted"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ Correctly rejected malformed UID '{malformedUid}': {ex.ErrorMessage}"); + } + catch (ArgumentException ex) + { + Console.WriteLine($"✅ Validation caught malformed UID '{malformedUid}': {ex.Message}"); + } + + // Add small delay to avoid rate limiting + await Task.Delay(100); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test110_Should_Handle_Large_ContentType_Lists() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for large list test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_LargeContentTypeList_BoundaryTest"); + + // Create a large list of content type UIDs (mix of valid and invalid for stress testing) + var largeContentTypeList = new List(); + + // Add available content types if we have them + if (_availableContentTypes.Count > 0) + { + largeContentTypeList.AddRange(_availableContentTypes); + } + + // Fill up to 50 items with dummy UIDs to test boundary conditions + for (int i = largeContentTypeList.Count; i < 50; i++) + { + largeContentTypeList.Add($"dummy_content_type_uid_{i:D3}"); + } + + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(largeContentTypeList); + + if (!linkResponse.IsSuccessStatusCode) + { + Console.WriteLine($"✅ API handled large content type list appropriately: {linkResponse.StatusCode}"); + + // Check if there's a limit mentioned in the error + var errorResponse = linkResponse.OpenResponse(); + if (errorResponse.Contains("limit") || errorResponse.Contains("maximum")) + { + Console.WriteLine(" Error response indicates API limits on batch size"); + } + } + else + { + Console.WriteLine($"✅ API successfully processed large content type list ({largeContentTypeList.Count} items)"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API rejected large list with appropriate error: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + catch (Exception ex) when (ex.Message.Contains("timeout") || ex.Message.Contains("request too large")) + { + Console.WriteLine($"✅ Infrastructure correctly handled oversized request: {ex.Message}"); + } + } + + #endregion + + #region Edge Cases and Boundary Tests + + [TestMethod] + [DoNotParallelize] + public async Task Test201_Should_Handle_Concurrent_Operations() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || _availableContentTypes.Count < 3) + { + Assert.Inconclusive("Prerequisites not met for concurrency test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_ConcurrentOperations_EdgeCase"); + + var tasks = new List>(); + + // Create multiple concurrent link operations + for (int i = 0; i < 3 && i < _availableContentTypes.Count; i++) + { + var contentType = _availableContentTypes[i]; + var task = _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(new List { contentType }); + tasks.Add(task); + } + + try + { + var responses = await Task.WhenAll(tasks); + + int successCount = responses.Count(r => r.IsSuccessStatusCode); + int failureCount = responses.Length - successCount; + + Console.WriteLine($"✅ Concurrent operations completed: {successCount} succeeded, {failureCount} failed"); + Console.WriteLine(" This tests API's handling of concurrent requests to the same resource"); + + // At least one should succeed or all should fail gracefully + Assert.IsTrue(successCount > 0 || failureCount == responses.Length, + "Either some operations should succeed or all should fail gracefully"); + } + catch (Exception ex) + { + Console.WriteLine($"✅ Concurrency test revealed system behavior: {ex.Message}"); + // This is acceptable - shows how the system handles concurrent operations + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test202_Should_Handle_Rapid_Link_Unlink_Sequence() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met for rapid sequence test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_RapidLinkUnlinkSequence_EdgeCase"); + + var contentTypeUids = new List { _testContentTypeUid }; + + try + { + // Rapid sequence: Link -> Unlink -> Link -> Unlink + var linkResponse1 = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + var unlinkResponse1 = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + var linkResponse2 = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + var unlinkResponse2 = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + Console.WriteLine($"✅ Rapid sequence completed:"); + Console.WriteLine($" Link 1: {linkResponse1.StatusCode}, Unlink 1: {unlinkResponse1.StatusCode}"); + Console.WriteLine($" Link 2: {linkResponse2.StatusCode}, Unlink 2: {unlinkResponse2.StatusCode}"); + Console.WriteLine(" This tests API's handling of rapid state changes"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API handled rapid sequence with appropriate response: {ex.ErrorMessage}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test203_Should_Handle_Special_Characters_In_ContentType_UIDs() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for special characters test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_SpecialCharactersInUIDs_EdgeCase"); + + var specialContentTypeUids = new List + { + "content_type_with_underscores", + "content-type-with-dashes", + "content.type.with.dots", + "CONTENT_TYPE_UPPERCASE", + "content123type456with789numbers" + }; + + foreach (var specialUid in specialContentTypeUids) + { + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(new List { specialUid }); + + Console.WriteLine($" Special UID '{specialUid}': {linkResponse.StatusCode}"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($" Special UID '{specialUid}' rejected: {ex.ErrorMessage}"); + } + + await Task.Delay(100); // Avoid rate limiting + } + + Console.WriteLine("✅ Special characters test completed - shows API's UID validation behavior"); + } + + [TestMethod] + [DoNotParallelize] + public async Task Test204_Should_Handle_Unicode_Characters() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for Unicode test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_UnicodeCharacters_EdgeCase"); + + var unicodeContentTypeUids = new List + { + "content_type_with_émojis_😀", + "content_type_中文_characters", + "content_type_العربية_text", + "content_type_русский_text" + }; + + foreach (var unicodeUid in unicodeContentTypeUids) + { + try + { + var linkResponse = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(new List { unicodeUid }); + + Console.WriteLine($" Unicode UID handled: {linkResponse.StatusCode}"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($" Unicode UID rejected appropriately: {ex.ErrorMessage}"); + } + catch (Exception ex) + { + Console.WriteLine($" Unicode UID caused encoding issue: {ex.Message}"); + } + + await Task.Delay(100); + } + + Console.WriteLine("✅ Unicode characters test completed"); + } + + #endregion + + #region Performance and Stress Tests + + [TestMethod] + [DoNotParallelize] + public async Task Test301_Should_Handle_Performance_Under_Load() + { + if (string.IsNullOrEmpty(_testVariantGroupUid)) + { + Assert.Inconclusive("No variant group available for performance test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_PerformanceUnderLoad_StressTest"); + + const int iterations = 10; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + int successCount = 0; + int failureCount = 0; + + var contentTypeUids = new List { _testContentTypeUid ?? "test_content_type" }; + + for (int i = 0; i < iterations; i++) + { + try + { + var response = await _stack.VariantGroup().FindAsync(); + + if (response.IsSuccessStatusCode) + { + successCount++; + } + else + { + failureCount++; + } + + // Small delay to avoid overwhelming the API + await Task.Delay(50); + } + catch (Exception) + { + failureCount++; + } + } + + stopwatch.Stop(); + + Console.WriteLine($"✅ Performance test completed:"); + Console.WriteLine($" {iterations} operations in {stopwatch.ElapsedMilliseconds}ms"); + Console.WriteLine($" Average: {stopwatch.ElapsedMilliseconds / iterations}ms per operation"); + Console.WriteLine($" Success rate: {successCount}/{iterations} ({(double)successCount / iterations * 100:F1}%)"); + + // At least 70% should succeed for a reasonable performance baseline + Assert.IsTrue((double)successCount / iterations >= 0.7, + $"Performance test should achieve at least 70% success rate, got {(double)successCount / iterations * 100:F1}%"); + } + + #endregion + + #region Authentication and Authorization Tests + + [TestMethod] + [DoNotParallelize] + public async Task Test401_Should_Fail_Without_Authentication() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_NoAuth_Negative"); + + // Create a client without authentication + var unauthenticatedClient = new ContentstackClient(); + var unauthenticatedStack = unauthenticatedClient.Stack(Contentstack.Config["Contentstack:Stack:api_key"]); + + try + { + var response = await unauthenticatedStack.VariantGroup().FindAsync(); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly failed without authentication: {response.StatusCode}"); + } + else + { + Assert.Fail("Expected authentication failure, but operation succeeded"); + } + } + catch (InvalidOperationException ex) when (ex.Message.Contains("not logged in")) + { + Console.WriteLine($"✅ Correctly caught authentication requirement: {ex.Message}"); + } + catch (ContentstackErrorException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + Console.WriteLine($"✅ API correctly rejected unauthenticated request: {ex.ErrorMessage}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test402_Should_Fail_With_Invalid_API_Key() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_InvalidAPIKey_Negative"); + + var invalidStack = _client.Stack("invalid_api_key_12345"); + + try + { + var response = await invalidStack.VariantGroup().FindAsync(); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Correctly failed with invalid API key: {response.StatusCode}"); + Console.WriteLine($" Error response: {response.OpenResponse()}"); + } + else + { + Assert.Fail("Expected API key validation failure, but operation succeeded"); + } + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ Correctly rejected invalid API key: {ex.ErrorMessage} (Code: {ex.ErrorCode})"); + } + } + + #endregion + + #region API State and Validation Tests + + [TestMethod] + [DoNotParallelize] + public async Task Test501_Should_Handle_Already_Linked_ContentTypes() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met for already-linked test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_AlreadyLinkedContentTypes_StateTest"); + + var contentTypeUids = new List { _testContentTypeUid }; + + try + { + // Attempt to link the same content type twice + var linkResponse1 = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + await Task.Delay(200); // Small delay between operations + + var linkResponse2 = await _stack + .VariantGroup(_testVariantGroupUid) + .LinkContentTypesAsync(contentTypeUids); + + Console.WriteLine($"✅ Double-link test completed:"); + Console.WriteLine($" First link: {linkResponse1.StatusCode}"); + Console.WriteLine($" Second link: {linkResponse2.StatusCode}"); + Console.WriteLine(" This tests API's idempotency for already-linked content types"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API handled double-link appropriately: {ex.ErrorMessage}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test502_Should_Handle_Already_Unlinked_ContentTypes() + { + if (string.IsNullOrEmpty(_testVariantGroupUid) || string.IsNullOrEmpty(_testContentTypeUid)) + { + Assert.Inconclusive("Prerequisites not met for already-unlinked test."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantGroup_AlreadyUnlinkedContentTypes_StateTest"); + + var contentTypeUids = new List { _testContentTypeUid }; + + try + { + // Attempt to unlink the same content type twice + var unlinkResponse1 = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + await Task.Delay(200); // Small delay between operations + + var unlinkResponse2 = await _stack + .VariantGroup(_testVariantGroupUid) + .UnlinkContentTypesAsync(contentTypeUids); + + Console.WriteLine($"✅ Double-unlink test completed:"); + Console.WriteLine($" First unlink: {unlinkResponse1.StatusCode}"); + Console.WriteLine($" Second unlink: {unlinkResponse2.StatusCode}"); + Console.WriteLine(" This tests API's idempotency for already-unlinked content types"); + } + catch (ContentstackErrorException ex) + { + Console.WriteLine($"✅ API handled double-unlink appropriately: {ex.ErrorMessage}"); + } + } + + #endregion + + #region Cleanup + + [TestMethod] + [DoNotParallelize] + public async Task Test999_Cleanup_Test_Resources() + { + TestOutputLogger.LogContext("TestScenario", "VariantGroup_Cleanup"); + + // Clean up any test data if needed + // Reset static variables + _testVariantGroupUid = null; + _testContentTypeUid = null; + _availableContentTypes.Clear(); + + Console.WriteLine("✅ Cleanup completed - all test variables reset"); + + // Add a small delay to ensure cleanup completes + await Task.Delay(100); + } + + #endregion + } +} \ No newline at end of file diff --git a/Contentstack.Management.Core.Unit.Tests/Services/Models/VariantContentTypeLinkServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Services/Models/VariantContentTypeLinkServiceTest.cs index 72ca190..8d69010 100644 --- a/Contentstack.Management.Core.Unit.Tests/Services/Models/VariantContentTypeLinkServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Services/Models/VariantContentTypeLinkServiceTest.cs @@ -33,6 +33,7 @@ public void Initialize_VariantContentTypeLinkService_For_Link() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid_1", "ct_uid_2" }; + string variantGroupUid = "test_variant_uid"; bool isLink = true; var service = new VariantContentTypeLinkService( @@ -40,11 +41,12 @@ public void Initialize_VariantContentTypeLinkService_For_Link() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink ); Assert.AreEqual(resourcePath, service.ResourcePath); - Assert.AreEqual("POST", service.HttpMethod); + Assert.AreEqual("PUT", service.HttpMethod); Assert.IsFalse(service.UseQueryString); } @@ -53,6 +55,7 @@ public void Initialize_VariantContentTypeLinkService_For_Unlink() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid_1", "ct_uid_2" }; + string variantGroupUid = "test_variant_uid"; bool isLink = false; var service = new VariantContentTypeLinkService( @@ -60,11 +63,12 @@ public void Initialize_VariantContentTypeLinkService_For_Unlink() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink ); Assert.AreEqual(resourcePath, service.ResourcePath); - Assert.AreEqual("POST", service.HttpMethod); + Assert.AreEqual("PUT", service.HttpMethod); Assert.IsFalse(service.UseQueryString); } @@ -76,6 +80,7 @@ public void Should_Throw_Exception_When_Stack_Is_Null() null, "/variant_groups/test_uid/content_types", new List { "ct_uid" }, + "test_variant_uid", true )); } @@ -88,6 +93,7 @@ public void Should_Throw_Exception_When_ResourcePath_Is_Null() _stack, null, new List { "ct_uid" }, + "test_variant_uid", true )); } @@ -100,6 +106,7 @@ public void Should_Throw_Exception_When_ContentTypeUids_Is_Null() _stack, "/variant_groups/test_uid/content_types", null, + "test_variant_uid", true )); } @@ -112,6 +119,7 @@ public void Should_Throw_Exception_When_ContentTypeUids_Is_Empty() _stack, "/variant_groups/test_uid/content_types", new List(), + "test_variant_uid", true )); } @@ -128,6 +136,7 @@ public void Should_Throw_Exception_When_Stack_APIKey_Is_Null() stackWithoutApiKey, "/variant_groups/test_uid/content_types", new List { "ct_uid" }, + "test_variant_uid", true )); } @@ -137,6 +146,7 @@ public void Should_Serialize_Link_Request_Body_Correctly() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid_1", "ct_uid_2" }; + string variantGroupUid = "test_variant_uid"; bool isLink = true; var service = new VariantContentTypeLinkService( @@ -144,6 +154,7 @@ public void Should_Serialize_Link_Request_Body_Correctly() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink ); @@ -171,6 +182,7 @@ public void Should_Serialize_Unlink_Request_Body_Correctly() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid_1", "ct_uid_2" }; + string variantGroupUid = "test_variant_uid"; bool isLink = false; var service = new VariantContentTypeLinkService( @@ -178,6 +190,7 @@ public void Should_Serialize_Unlink_Request_Body_Correctly() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink ); @@ -188,6 +201,11 @@ public void Should_Serialize_Unlink_Request_Body_Correctly() // Parse the JSON to verify structure var jsonObject = JsonConvert.DeserializeObject(requestBody); + + // Check root properties + Assert.AreEqual("test_variant_uid", (string)jsonObject.uid); + Assert.IsNotNull(jsonObject.branches); + Assert.IsNotNull(jsonObject.content_types); var contentTypes = jsonObject.content_types; @@ -205,6 +223,7 @@ public void Should_Serialize_Single_Content_Type_Correctly() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "single_ct_uid" }; + string variantGroupUid = "test_variant_uid"; bool isLink = true; var service = new VariantContentTypeLinkService( @@ -212,6 +231,7 @@ public void Should_Serialize_Single_Content_Type_Correctly() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink ); @@ -222,6 +242,11 @@ public void Should_Serialize_Single_Content_Type_Correctly() // Parse the JSON to verify structure var jsonObject = JsonConvert.DeserializeObject(requestBody); + + // Check root properties + Assert.AreEqual("test_variant_uid", (string)jsonObject.uid); + Assert.IsNotNull(jsonObject.branches); + Assert.IsNotNull(jsonObject.content_types); var contentTypes = jsonObject.content_types; @@ -236,6 +261,7 @@ public void Should_Work_With_Query_Parameters() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid" }; + string variantGroupUid = "test_variant_uid"; bool isLink = true; var parameters = new ParameterCollection(); parameters.Add("include_count", "true"); @@ -245,12 +271,13 @@ public void Should_Work_With_Query_Parameters() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink, parameters ); Assert.AreEqual(resourcePath, service.ResourcePath); - Assert.AreEqual("POST", service.HttpMethod); + Assert.AreEqual("PUT", service.HttpMethod); // UseQueryString should be true when parameters are provided Assert.IsTrue(service.UseQueryString); } @@ -260,6 +287,7 @@ public void Should_Handle_Empty_Query_Parameters() { string resourcePath = "/variant_groups/test_uid/content_types"; List contentTypeUids = new List { "ct_uid" }; + string variantGroupUid = "test_variant_uid"; bool isLink = true; var service = new VariantContentTypeLinkService( @@ -267,12 +295,13 @@ public void Should_Handle_Empty_Query_Parameters() _stack, resourcePath, contentTypeUids, + variantGroupUid, isLink, null ); Assert.AreEqual(resourcePath, service.ResourcePath); - Assert.AreEqual("POST", service.HttpMethod); + Assert.AreEqual("PUT", service.HttpMethod); Assert.IsFalse(service.UseQueryString); } } diff --git a/Contentstack.Management.Core/Models/VariantGroup.cs b/Contentstack.Management.Core/Models/VariantGroup.cs index bb9dac2..8dfcf01 100644 --- a/Contentstack.Management.Core/Models/VariantGroup.cs +++ b/Contentstack.Management.Core/Models/VariantGroup.cs @@ -112,6 +112,7 @@ public ContentstackResponse LinkContentTypes( stack, $"{resourcePath}/variants", contentTypeUids, + this.Uid, true, collection ); @@ -145,6 +146,7 @@ public Task LinkContentTypesAsync( stack, $"{resourcePath}/variants", contentTypeUids, + this.Uid, true, collection ); @@ -180,6 +182,7 @@ public ContentstackResponse UnlinkContentTypes( stack, $"{resourcePath}/variants", contentTypeUids, + this.Uid, false, collection ); @@ -213,6 +216,7 @@ public Task UnlinkContentTypesAsync( stack, $"{resourcePath}/variants", contentTypeUids, + this.Uid, false, collection ); diff --git a/Contentstack.Management.Core/Services/Models/VariantContentTypeLinkService.cs b/Contentstack.Management.Core/Services/Models/VariantContentTypeLinkService.cs index 441bc43..2a2a011 100644 --- a/Contentstack.Management.Core/Services/Models/VariantContentTypeLinkService.cs +++ b/Contentstack.Management.Core/Services/Models/VariantContentTypeLinkService.cs @@ -10,6 +10,7 @@ namespace Contentstack.Management.Core.Services.Models internal class VariantContentTypeLinkService : ContentstackService { private readonly List _contentTypeUids; + private readonly string _variantGroupUid; private readonly bool _isLink; internal VariantContentTypeLinkService( @@ -17,6 +18,7 @@ internal VariantContentTypeLinkService( Core.Models.Stack stack, string resourcePath, List contentTypeUids, + string variantGroupUid, bool isLink, ParameterCollection collection = null ) @@ -45,8 +47,9 @@ internal VariantContentTypeLinkService( } this.ResourcePath = resourcePath; - this.HttpMethod = "POST"; + this.HttpMethod = "PUT"; _contentTypeUids = contentTypeUids; + _variantGroupUid = variantGroupUid; _isLink = isLink; if (collection != null && collection.Count > 0) @@ -61,9 +64,9 @@ public override void ContentBody() { JsonWriter writer = new JsonTextWriter(stringWriter); writer.WriteStartObject(); + writer.WritePropertyName("content_types"); writer.WriteStartArray(); - foreach (var uid in _contentTypeUids) { writer.WriteStartObject(); @@ -73,8 +76,16 @@ public override void ContentBody() writer.WriteValue(_isLink ? "linked" : "unlinked"); writer.WriteEndObject(); } + writer.WriteEndArray(); + + writer.WritePropertyName("uid"); + writer.WriteValue(_variantGroupUid); + writer.WritePropertyName("branches"); + writer.WriteStartArray(); + writer.WriteValue("main"); writer.WriteEndArray(); + writer.WriteEndObject(); this.ByteContent = System.Text.Encoding.UTF8.GetBytes(stringWriter.ToString()); diff --git a/Scripts/run-integration-tests-with-report.sh b/Scripts/run-integration-tests-with-report.sh index 662e10f..8d2bac4 100755 --- a/Scripts/run-integration-tests-with-report.sh +++ b/Scripts/run-integration-tests-with-report.sh @@ -14,20 +14,52 @@ echo "Project: $PROJECT_ROOT" echo "Run ID: $TIMESTAMP" echo "" +if ! command -v dotnet >/dev/null 2>&1; then + echo "Error: 'dotnet' was not found on your PATH." + echo "Install the .NET SDK (same major version as the test projects) and open a new terminal, or see:" + echo " https://dotnet.microsoft.com/download" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: 'python3' was not found on your PATH (required for the HTML report)." + exit 1 +fi + +SETTINGS="$PROJECT_ROOT/$TEST_PROJECT/appsettings.json" +if [ ! -f "$SETTINGS" ]; then + echo "Error: Integration tests require credentials at:" + echo " $SETTINGS" + echo "Copy the template and fill in real values (do not commit secrets):" + echo " cp \"$PROJECT_ROOT/$TEST_PROJECT/appsettings.json.example\" \"$SETTINGS\"" + exit 1 +fi + # Step 1: Run ONLY integration tests, collect TRX + coverage TRX_FILE="IntegrationTest-Report-${TIMESTAMP}.trx" +TRX_PATH="$PROJECT_ROOT/$TEST_PROJECT/TestResults/$TRX_FILE" echo "Step 1: Running integration tests..." +set +e dotnet test "$PROJECT_ROOT/$TEST_PROJECT/$TEST_PROJECT.csproj" \ --filter "FullyQualifiedName~IntegrationTest" \ --logger "trx;LogFileName=$TRX_FILE" \ --results-directory "$PROJECT_ROOT/$TEST_PROJECT/TestResults" \ --collect:"XPlat code coverage" \ - --verbosity quiet || true + --verbosity quiet +TEST_EXIT=$? +set -e echo "" -echo "Tests completed." +echo "Tests completed (dotnet exit code: $TEST_EXIT)." echo "" +if [ ! -f "$TRX_PATH" ]; then + echo "Error: TRX file was not created at:" + echo " $TRX_PATH" + echo "Fix the dotnet/test errors above (or install the SDK), then run this script again." + exit 1 +fi + # Step 2: Locate the cobertura coverage file (most recent) COBERTURA="" if [ -d "$PROJECT_ROOT/$TEST_PROJECT/TestResults" ]; then @@ -35,7 +67,6 @@ if [ -d "$PROJECT_ROOT/$TEST_PROJECT/TestResults" ]; then -name "coverage.cobertura.xml" 2>/dev/null | sort -r | head -1) fi -TRX_PATH="$PROJECT_ROOT/$TEST_PROJECT/TestResults/$TRX_FILE" echo "TRX: $TRX_PATH" echo "Coverage: ${COBERTURA:-Not found}" echo "" diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md index 62e62e6..e3495f6 100644 --- a/skills/code-review/SKILL.md +++ b/skills/code-review/SKILL.md @@ -14,8 +14,7 @@ description: Use when reviewing or preparing a pull request for contentstack-man ### Branch and merge expectations -- **Typical PRs** should target **`development`**. Use **`main`** as the base branch only for **hotfixes**. -- **When the base is `main`:** only PRs from **`staging`** are allowed (enforced by [`.github/workflows/check-branch.yml`](../../.github/workflows/check-branch.yml)). Confirm head/base match team intent before approving. +- **Feature/fix PRs** target **`development`**. **Release PRs** use **`development` → `main`** (no `staging`). Confirm head/base match the intended step in that flow before approving. ### Summary checklist @@ -42,8 +41,8 @@ Copy sections into a PR comment when useful. This checklist is for **this** repo #### Branch policy ```markdown -- [ ] **Default:** PR targets **`development`** unless this is a documented **hotfix** to **`main`** -- [ ] If base is **`main`**: head branch is **`staging`** (see `.github/workflows/check-branch.yml`) +- [ ] **Feature/fix:** PR targets **`development`** +- [ ] **Release:** if merging to **`main`**, this is the agreed **`development` → `main`** release PR (not a bypass of version/tag checks) ``` #### Breaking changes diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md index 68fef13..786617d 100644 --- a/skills/dev-workflow/SKILL.md +++ b/skills/dev-workflow/SKILL.md @@ -15,17 +15,19 @@ description: Use for branches, CI, build/test scripts, and NuGet release flow in ### Branch policy -- **Default workflow:** open PRs against **`development`** for regular feature and fix work. **`main`** is reserved for **hotfixes** (PRs raised directly to `main` only when patching production). -- **When the PR target is `main`:** GitHub Actions requires the head branch to be **`staging`**—other head branches are rejected by [`.github/workflows/check-branch.yml`](../../.github/workflows/check-branch.yml). Coordinate with SRE/release if a hotfix must use a different flow. -- Do not bypass enforced checks without org approval. +- **Default:** open PRs against **`development`** for feature and fix work. +- **Releases:** open a **release PR `development` → `main`** (no `staging`). After `main` is updated, [`.github/workflows/back-merge-pr.yml`](../../.github/workflows/back-merge-pr.yml) opens **`main` → `development`** when needed so branches stay aligned. +- **Publishing:** create a **GitHub Release** (after the release commit is on `main`) to trigger [`.github/workflows/nuget-publish.yml`](../../.github/workflows/nuget-publish.yml) (`release: created`). +- **Version gate:** PRs that touch product code or `Directory.Build.props` need matching bumps in `Directory.Build.props` and `CHANGELOG.md` per [`.github/workflows/check-version-bump.yml`](../../.github/workflows/check-version-bump.yml). ### Key workflows | Workflow | Role | | -------- | ---- | | [`unit-test.yml`](../../.github/workflows/unit-test.yml) | On PR and push: runs [`Scripts/run-unit-test-case.sh`](../../Scripts/run-unit-test-case.sh) (unit tests + TRX + coverlet). | -| [`check-branch.yml`](../../.github/workflows/check-branch.yml) | For PRs **into `main`**, enforces head branch **`staging`**. | -| [`nuget-publish.yml`](../../.github/workflows/nuget-publish.yml) | On release: `dotnet pack -c Release -o out` and push to NuGet / GitHub Packages. | +| [`back-merge-pr.yml`](../../.github/workflows/back-merge-pr.yml) | After pushes to **`main`**, opens **`main` → `development`** PR if needed. | +| [`check-version-bump.yml`](../../.github/workflows/check-version-bump.yml) | On PR: requires version + changelog when SDK sources / props change. | +| [`nuget-publish.yml`](../../.github/workflows/nuget-publish.yml) | On **GitHub Release** (`created`): `dotnet pack -c Release -o out` and push to NuGet / GitHub Packages. | | [`policy-scan.yml`](../../.github/workflows/policy-scan.yml), [`sca-scan.yml`](../../.github/workflows/sca-scan.yml) | Security / compliance scans. | ### Local commands