diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..f5c1f87 --- /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/.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-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 0000000..6413c97 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,110 @@ +# Release-affecting changes under contentstack_management/ or setup.py require +# __version__ + CHANGELOG.md updates aligned with the 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 + if [[ "$f" == contentstack_management/* ]] || [[ "$f" == "setup.py" ]]; then + CODE_CHANGED=true + break + fi + done <<< "$FILES" + + INIT_CHANGED=false + CHANGELOG_CHANGED=false + echo "$FILES" | grep -qx 'contentstack_management/__init__.py' && INIT_CHANGED=true + echo "$FILES" | grep -qx 'CHANGELOG.md' && CHANGELOG_CHANGED=true + + VERSION_FILES_OK=false + if [ "$INIT_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 contentstack_management or setup.py 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 __version__ in contentstack_management/__init__.py and add a ## vX.Y.Z section in 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 + PKG_VERSION=$(python3 <<'PY' + import re + text = open("contentstack_management/__init__.py").read() + m = re.search(r"^__version__\s*=\s*['\"]([^'\"]+)['\"]", text, re.MULTILINE) + if not m: + raise SystemExit("Could not read __version__ from contentstack_management/__init__.py") + 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" != "$PKG_VERSION" ]; then + echo "::error::CHANGELOG top version ($CHANGELOG_HEAD) does not match __version__ ($PKG_VERSION)." + exit 1 + fi + exit 0 + fi + + LATEST_VERSION="${LATEST_TAG#v}" + LATEST_VERSION="${LATEST_VERSION%%-*}" + if [ "$(printf '%s\n' "$LATEST_VERSION" "$PKG_VERSION" | sort -V | tail -1)" != "$PKG_VERSION" ]; then + echo "::error::__version__ ($PKG_VERSION) must be greater than latest tag ($LATEST_TAG)." + exit 1 + fi + if [ "$PKG_VERSION" = "$LATEST_VERSION" ]; then + echo "::error::__version__ ($PKG_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" != "$PKG_VERSION" ]; then + echo "::error::CHANGELOG top version ($CHANGELOG_HEAD) does not match __version__ ($PKG_VERSION)." + exit 1 + fi + echo "Version bump check passed: __version__ and CHANGELOG at $PKG_VERSION (latest tag: $LATEST_TAG)." diff --git a/.github/workflows/issues-jira.yml b/.github/workflows/issues-jira.yml deleted file mode 100644 index 7bf0469..0000000 --- a/.github/workflows/issues-jira.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Create Jira Ticket for Github Issue - -on: - issues: - types: [opened] - -jobs: - issue-jira: - runs-on: ubuntu-latest - steps: - - - name: Login to Jira - uses: atlassian/gajira-login@master - env: - JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - - - name: Create Jira Issue - id: create_jira - uses: atlassian/gajira-create@master - with: - project: ${{ secrets.JIRA_PROJECT }} - issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} - summary: Github | Issue | ${{ github.event.repository.name }} | ${{ github.event.issue.title }} - description: | - *GitHub Issue:* ${{ github.event.issue.html_url }} - - *Description:* - ${{ github.event.issue.body }} - fields: "${{ secrets.ISSUES_JIRA_FIELDS }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3030607..87152fc 100644 --- a/.gitignore +++ b/.gitignore @@ -133,8 +133,17 @@ tests/config/default.yml .vscode/settings.json run.py tests/resources/.DS_Store -.talismanrc tests/.DS_Store -tests/resources/.DS_Store .DS_Store -.talismanrc \ No newline at end of file +*/data/regions.json +# Local backup of legacy tests (do not commit) + +# --- CMA integration suite: do not track --- +docs/ +tests_backup_legacy/ +tests/integration/report/ +tests/integration/.env +tests/integration/.env.example +# Timestamped reports written at repo root +cma-python-report-*.html +api-requests-*.txt diff --git a/.talismanrc b/.talismanrc index a80d4de..62b9058 100644 --- a/.talismanrc +++ b/.talismanrc @@ -406,3 +406,7 @@ fileignoreconfig: - filename: tests/mock/assets/test_assets_mock.py checksum: 12c9091bb88c0c12712f046b29fb4a9dce3b95ecc45f4ea46bbc3fd4529742a0 version: "1.0" +fileignoreconfig: +- filename: tests/integration/framework/capture.py + checksum: c9680c4ee3e1def0765fd89767fa1383aee5d195222fa021a19618607373047c +version: "1.0" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..81fc1b3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Contentstack Management Python – Agent guide + +**Universal entry point** for contributors and AI agents. Detailed conventions live in **`skills/*/SKILL.md`**. + +## What this repo is + +| Field | Detail | +|--------|--------| +| **Name:** | **`contentstack-management`** (PyPI) — [contentstack/contentstack-management-python](https://github.com/contentstack/contentstack-management-python) | +| **Purpose:** | Python client for the **Content Management API (CMA)**: organizations, stacks, content types, entries, assets, webhooks, workflows, OAuth, and related resources. Uses **`requests`** via **`_APIClient`**. | + +## Tech stack (at a glance) + +| Area | Details | +|------|---------| +| Language | Python ≥ 3.9 (`setup.py` `python_requires`) | +| Build | `setuptools` / `setup.py`; package `contentstack_management` | +| HTTP | `requests`, `requests-toolbelt`, `urllib3` | +| Tests | `pytest` — `tests/integration` (live e2e / sanity, dynamic stack), `tests/unit`, `tests/mock`, `tests/api` (legacy, superseded by `tests/integration`) | +| Lint / coverage | `pylint`, `coverage` (see `requirements.txt`) | +| Secrets / hooks | Talisman, Snyk (see `README.md` development setup) | + +## Source layout + +| Path | Role | +|------|------| +| `contentstack_management/contentstack.py` | **`Client`**, **`Region`**, endpoint construction, **`user_agents`**, optional **OAuth** wiring | +| `contentstack_management/_api_client.py` | **`_APIClient`** — HTTP calls, retries, optional **OAuth** interceptor | +| `contentstack_management/stack/stack.py` | **Stack**-scoped CMA operations | +| `contentstack_management/*/` | Domain modules (entries, assets, webhooks, taxonomies, …) | +| `contentstack_management/__init__.py` | Public exports | +| `tests/integration/` | **Live e2e / sanity suite** (pytest). Self-contained: creates a fresh stack per run, exercises every SDK method (positive/negative/edge), tears it down. Own `framework/` + `data/`; config in `tests/integration/.env`. | +| `tests/cred.py` | **`get_credentials()`** — **dotenv** + env vars for the legacy `tests/api` / `tests/mock` suites | + +## Commands (quick reference) + +| Command Type | Command | +|---|---| +| Install | `pip install -e ".[dev]"` | +| **Sanity / e2e (live)** | `pytest tests/integration` — dynamically creates a stack, runs the full suite, tears it down. Needs `tests/integration/.env` (`EMAIL`, `PASSWORD`, `HOST`, `ORGANIZATION`). Writes a timestamped HTML report + cURL log to the repo root. | +| Sanity, keep stack | `DELETE_DYNAMIC_RESOURCES=false pytest tests/integration` (preserve the created stack for debugging) | +| Sanity, one resource | `pytest tests/integration/api/test_12_content_type.py` | +| Test (unit) | `pytest tests/unit/ -v` | +| Test (mock) | `pytest tests/mock/ -v` | +| Test (legacy API, live) | `pytest tests/api/ -v` (needs `.env` — see `tests/cred.py`) | +| Coverage (CI) | `coverage run -m pytest tests/unit/` | +| Lint | `pylint contentstack_management/` | + +> **CI note:** `.github/workflows/unit-test.yml` runs **only `tests/unit/`** (no credentials). The `tests/integration` sanity suite is run manually (or via a credential-gated job) because it provisions real stacks. + +## Environment variables + +**Sanity / e2e suite** (`tests/integration`) — configured via **`tests/integration/.env`** (gitignored). No pre-existing stack/UIDs needed; the suite creates everything at runtime. + +| Var | Required | Purpose | +|-----|----------|---------| +| `EMAIL`, `PASSWORD` | ✅ | Login for the run (a **non-2FA** account) | +| `HOST` | ✅ | API host (e.g. `api.contentstack.io`) | +| `ORGANIZATION` | ✅ | Org the dynamic test stack is created in | +| `MFA_SECRET` | — | TOTP secret (for the OAuth/2FA account, not the primary login) | +| `DELETE_DYNAMIC_RESOURCES` | — | `false` keeps the created stack for debugging (default deletes) | +| `CLIENT_ID`, `APP_ID`, `REDIRECT_URI` | — | OAuth tests | +| `PERSONALIZE_HOST` | — | Personalize project for variant tests | + +**Legacy `tests/api` / `tests/mock`** — loaded via **`tests/cred.py`** (`load_dotenv()`): `HOST`, `APIKEY`, `AUTHTOKEN`, `MANAGEMENT_TOKEN`, `ORG_UID`, and resource UIDs. See that file for the full list. + +Do not commit secrets. `tests/integration/.env`, `docs/`, and the repo-root `cma-python-report-*.html` / `api-requests-*.txt` are gitignored. + +## Where the documentation lives: skills + +| Skill | Path | What it covers | +|-------|------|----------------| +| **Development workflow** | [`skills/dev-workflow/SKILL.md`](skills/dev-workflow/SKILL.md) | Install, pytest suites, packaging version, pylint, hooks, PR baseline | +| **Contentstack Management (SDK)** | [`skills/contentstack-management/SKILL.md`](skills/contentstack-management/SKILL.md) | **`Client`**, **`Stack`**, **`_APIClient`**, CMA resources, OAuth, CMA docs | +| **Python style & repo layout** | [`skills/python-style/SKILL.md`](skills/python-style/SKILL.md) | Package layout, naming, imports via **`_APIClient`**, secrets in logs | +| **Testing** | [`skills/testing/SKILL.md`](skills/testing/SKILL.md) | pytest unit / API / mock, **`tests/cred.py`**, env hygiene | +| **Code review** | [`skills/code-review/SKILL.md`](skills/code-review/SKILL.md) | PR checklist—public API, HTTP/auth, tests, security | +| **Framework / HTTP** | [`skills/framework/SKILL.md`](skills/framework/SKILL.md) | **`requests`**, retries, OAuth interceptor, where to change transport | + +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`**—same docs as everyone else. + +Product docs: [Content Management API](https://www.contentstack.com/docs/developers/apis/content-management-api/). diff --git a/CHANGELOG.md b/CHANGELOG.md index 910bc15..7f75064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,66 @@ # CHANGELOG ## Content Management SDK For Python +--- +## v1.11.1 + +#### Date: 22 July 2026 + +- Bumped `setuptools` minimum version from `>=80.0.0,<82.0.0` to `>=83.0.0` to resolve a Snyk vulnerability finding. + +--- +## v1.11.0 + +#### Date: 20 July 2026 + +- Entry variants: added `publish` and `unpublish` for the entry publish/unpublish endpoints; documented payloads including `entry.variants` and optional `entry.variant_rules` on publish. +- Entry variants: optional stack branch via the `branch` request header; `Entry.variants()` accepts no arguments, a branch UID only, or `(variant_uid, branch)` (use `variants(variant_uid, None)` when targeting a variant without a branch). + +--- +## v1.10.2 + +#### Date: 13 July 2026 + +- Removed hardcoded password literals in unit tests to resolve Snyk CWE-798 (Use of Hardcoded Passwords) findings. + +--- +## v1.10.1 + +#### Date: 26 June 2026 + +- Fixed `Asset.update()` to send the JSON body with `Content-Type: application/json` instead of an invalid bare `multipart/form-data`, which the API rejected with 422. +- Fixed `Asset.replace()` to let the HTTP layer set `multipart/form-data` with a proper boundary (a bare `multipart/form-data` header without a boundary previously caused a 422). Both fixes also remove a side effect that leaked the wrong `Content-Type` onto subsequent requests. + +--- +## v1.10.0 + +#### Date: 22 June 2026 + +- Dynamic region endpoint resolution via the Contentstack Regions Registry (`regions.json`). +- Added `Endpoint` class with 3-tier resolution: in-memory cache → bundled `data/regions.json` → live CDN download. +- Exposed `contentstack_management.get_contentstack_endpoint(region, service, omit_https)` module-level proxy. +- `Client` now resolves the `contentManagement` endpoint from the registry instead of a hardcoded host pattern. +- Bundled `contentstack_management/data/regions.json` included in `package_data` — always present after `pip install`. +- `setup.py` auto-refreshes `regions.json` at build time via a custom `BuildPyWithRegions` command; network failures warn but never block the build. +- Runtime fallback: if `regions.json` is absent, the SDK downloads it live on the first `Endpoint` call. +- New regions and services require no SDK code changes — registry update is sufficient. +- Added `refresh_regions()` utility to programmatically download the latest regions manifest from the Contentstack CDN and overwrite the bundled `data/regions.json` (`from contentstack_management import refresh_regions`). +- Added `python3 -m contentstack_management.region_refresh` CLI command for refreshing the registry after `pip install` (source-tree script `scripts/download_regions.py` is for contributors only). + +--- +## v1.9.0 + +#### Date: 01 June 2026 + +- Removed unused `bson` dependency so the package installs on Python 3.12+ and 3.14 (the standalone `bson` 0.5.x package fails to build on newer Python versions). + +--- +## v1.8.1 + +#### Date: 15 April 2026 + +- Fixed Security issues + --- ## v1.8.0 diff --git a/contentstack_management/__init__.py b/contentstack_management/__init__.py index 6d55586..4e9a763 100644 --- a/contentstack_management/__init__.py +++ b/contentstack_management/__init__.py @@ -18,6 +18,7 @@ from .entries.entry import Entry from .entry_variants.entry_variants import EntryVariants from .contentstack import Client, Region +from .endpoint import Endpoint from ._api_client import _APIClient from .common import Parameter from ._errors import ArgumentException @@ -36,11 +37,13 @@ from .variants.variants import Variants from .oauth.oauth_handler import OAuthHandler from .oauth.oauth_interceptor import OAuthInterceptor +from .region_refresh import refresh_regions __all__ = ( "Client", "Region", +"Endpoint", "_APIClient", "Parameter", "ArgumentException", @@ -75,14 +78,31 @@ "VariantGroup", "Variants", "OAuthHandler", -"OAuthInterceptor" +"OAuthInterceptor", +"refresh_regions", ) +def get_contentstack_endpoint(region='us', service='', omit_https=False): + """ + Resolve a Contentstack service endpoint URL for a given region. + + Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience — + mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK. + + :param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...). + :param service: Service key ('contentDelivery', 'contentManagement', ...). + When empty, returns a dict of all endpoints for the region. + :param omit_https: When True, strips 'https://' from the returned URL(s). + :returns: str when service is provided, dict[str,str] otherwise. + """ + return Endpoint.get_contentstack_endpoint(region, service, omit_https) + + __title__ = 'contentstack-management-python' __author__ = 'dev-ex' __status__ = 'debug' __region__ = 'na' -__version__ = '1.8.0' +__version__ = '1.11.1' __host__ = 'api.contentstack.io' __protocol__ = 'https://' __api_version__ = 'v3' diff --git a/contentstack_management/assets/assets.py b/contentstack_management/assets/assets.py index 69ad31d..420b519 100644 --- a/contentstack_management/assets/assets.py +++ b/contentstack_management/assets/assets.py @@ -174,9 +174,17 @@ def replace(self, file_path): """ url = f"assets/{self.asset_uid}" - Parameter.add_header(self, "Content-Type", "multipart/form-data") - files = {"asset": open(f"{file_path}",'rb')} - return self.client.put(url, headers = self.client.headers, params = self.params, files = files) + # _api_client._call_request does headers.update(self.headers) so a per-request + # copy is overwritten before the request is sent. The only way to suppress + # Content-Type is to temporarily remove it from the shared dict so requests can + # set multipart/form-data with the correct boundary automatically. + saved_ct = self.client.headers.pop("Content-Type", None) + try: + with open(file_path, 'rb') as fh: + return self.client.put(url, headers=self.client.headers, params=self.params, files={"asset": fh}) + finally: + if saved_ct is not None: + self.client.headers["Content-Type"] = saved_ct def generate(self, data): """ @@ -407,8 +415,11 @@ def update(self, data): if self.asset_uid is None or '': raise Exception(ASSET_UID_REQUIRED) url = f"assets/{self.asset_uid}" - Parameter.add_header(self, "Content-Type", "multipart/form-data") - return self.client.put(url, headers = self.client.headers, params = self.params, data = data) + # Use a per-request header copy with Content-Type explicitly set to application/json + # so this call is not affected by whatever a preceding upload/replace left in + # the shared headers dict. + request_headers = {**self.client.headers, "Content-Type": "application/json"} + return self.client.put(url, headers=request_headers, params=self.params, data=data) def publish(self, data): """ diff --git a/contentstack_management/contentstack.py b/contentstack_management/contentstack.py index 09192cf..2ad6198 100644 --- a/contentstack_management/contentstack.py +++ b/contentstack_management/contentstack.py @@ -2,6 +2,7 @@ import os import pyotp from ._api_client import _APIClient +from .endpoint import Endpoint from contentstack_management.organizations import organization from contentstack_management.stack import stack from contentstack_management.user_session import user_session @@ -37,16 +38,25 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://', authtoken: str = None , management_token=None, headers: dict = None, region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None, oauth_config: dict = None, **kwargs): - self.endpoint = 'https://api.contentstack.io/v3/' - - if region is not None and region is not Region.US.value: - if host is not None and host != 'api.contentstack.io': + _DEFAULT_HOST = 'api.contentstack.io' + self.endpoint = f'{scheme}{_DEFAULT_HOST}/{version}/' + + if host is None or host == _DEFAULT_HOST: + # No custom host — resolve via Endpoint (regions.json-driven) + try: + base = Endpoint.get_contentstack_endpoint( + region or 'us', 'contentManagement', omit_https=True) + self.endpoint = f'{scheme}{base}/{version}/' + except (ValueError, RuntimeError): + # Unknown/custom region string — fall back to legacy pattern + if region and region != Region.US.value: + self.endpoint = f'{scheme}{region}-api.contentstack.com/{version}/' + else: + # Explicit custom host always wins; apply region prefix when non-US + if region and region != Region.US.value: self.endpoint = f'{scheme}{region}-api.{host}/{version}/' else: - host = 'api.contentstack.com' - self.endpoint = f'{scheme}{region}-{host}/{version}/' - elif host is not None and host != 'api.contentstack.io': - self.endpoint = f'{scheme}{host}/{version}/' + self.endpoint = f'{scheme}{host}/{version}/' if headers is None: headers = {} if early_access is not None: diff --git a/contentstack_management/endpoint.py b/contentstack_management/endpoint.py new file mode 100644 index 0000000..cf2801f --- /dev/null +++ b/contentstack_management/endpoint.py @@ -0,0 +1,180 @@ +""" +Endpoint — Contentstack region-to-URL resolver for the Management SDK. + +Resolves Contentstack service endpoint URLs for any supported region. +Region data is loaded from contentstack_management/data/regions.json (bundled) +and cached in-memory for the lifetime of the process. When the bundled file is +absent the class attempts a live download from the Contentstack CDN so the +SDK continues to work even when the file was not created during installation. +""" + +import json +import os +import re + +REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + + +class Endpoint: + """ + Resolves Contentstack service endpoint URLs for any supported region. + + Usage:: + + from contentstack_management.endpoint import Endpoint + + # Single service URL + url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') + # 'https://eu-api.contentstack.com' + + # All services for a region + endpoints = Endpoint.get_contentstack_endpoint('azure-na') + # {'contentDelivery': '...', 'contentManagement': '...', ...} + + # Strip scheme (useful when building endpoint strings manually) + host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement', omit_https=True) + # 'gcp-eu-api.contentstack.com' + """ + + _regions_data = None # in-memory cache — shared across all instances + + @staticmethod + def get_contentstack_endpoint(region='us', service='', omit_https=False): + """ + Resolve a Contentstack service endpoint URL for a given region. + + :param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', etc.). + Defaults to 'us' (AWS North America). + :param service: Service key ('contentDelivery', 'contentManagement', ...). + When empty, returns a dict of all endpoints for the region. + :param omit_https: When True, strips 'https://' prefix from returned URL(s). + :returns: str when service is provided, dict[str,str] otherwise. + :raises ValueError: When region is empty, unknown, or service is not found. + :raises RuntimeError: When regions.json cannot be read or parsed. + """ + if not region: + raise ValueError('Empty region provided. Please put valid region.') + + data = Endpoint._load_regions() + normalized = region.strip().lower() + region_row = Endpoint._find_region(data['regions'], normalized) + + if region_row is None: + raise ValueError(f'Invalid region: {region}') + + if service: + if service not in region_row['endpoints']: + raise ValueError( + f'Service "{service}" not found for region "{region_row["id"]}"' + ) + url = region_row['endpoints'][service] + return Endpoint._strip_https(url) if omit_https else url + + endpoints = region_row['endpoints'] + if omit_https: + return {k: Endpoint._strip_https(v) for k, v in endpoints.items()} + return dict(endpoints) + + @staticmethod + def _load_regions(): + """ + Load and cache regions.json. + + Resolution order: + 1. In-memory static cache (zero I/O after first call) + 2. contentstack_management/data/regions.json on disk (written by download script) + 3. Live download from artifacts.contentstack.com (fallback) + """ + if Endpoint._regions_data is not None: + return Endpoint._regions_data + + data_dir = os.path.join(os.path.dirname(__file__), 'data') + path = os.path.join(data_dir, 'regions.json') + + if not os.path.exists(path): + Endpoint._download_and_save(path) + + if not os.path.exists(path): + raise RuntimeError( + 'contentstack-management: regions.json not found and could not be downloaded. ' + 'Run "python scripts/download_regions.py" and ensure network access.' + ) + + try: + with open(path, 'r', encoding='utf-8') as f: + decoded = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError( + f'contentstack-management: Could not read or parse regions.json: {exc}. ' + 'Run "python scripts/download_regions.py" to re-download it.' + ) from exc + + if not isinstance(decoded, dict) or 'regions' not in decoded: + raise RuntimeError( + 'contentstack-management: regions.json is corrupt. ' + 'Run "python scripts/download_regions.py" to re-download it.' + ) + + Endpoint._regions_data = decoded + return Endpoint._regions_data + + @staticmethod + def _download_and_save(dest): + """ + Download regions.json from the Contentstack CDN and save to disk. + Uses the requests library (already an SDK dependency). + Silent on failure — the caller decides whether a missing file is fatal. + + :param dest: Absolute path to write the file to. + """ + os.makedirs(os.path.dirname(dest), exist_ok=True) + + try: + import requests + response = requests.get(REGIONS_URL, timeout=30) + response.raise_for_status() + data = response.text + except Exception: # noqa: BLE001 + return + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + return + + if isinstance(decoded, dict) and 'regions' in decoded: + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write(data) + except OSError: + pass + + @staticmethod + def _find_region(regions, input_str): + """ + Find a region entry by its id or any alias (case-insensitive). + + Two-pass: exact id match first, then alias[] scan — mirrors PHP implementation. + + :param regions: list of region dicts from regions.json + :param input_str: already-lowercased input + :returns: region dict or None + """ + for row in regions: + if row['id'] == input_str: + return row + for row in regions: + for alias in row.get('alias', []): + if alias.lower() == input_str: + return row + return None + + @staticmethod + def _strip_https(url): + """Strip the https:// (or http://) scheme from a URL string.""" + return re.sub(r'^https?://', '', url) + + @staticmethod + def reset_cache(): + """Reset the internal region cache. Intended for testing only.""" + Endpoint._regions_data = None diff --git a/contentstack_management/entries/entry.py b/contentstack_management/entries/entry.py index 3422eca..0c643e6 100644 --- a/contentstack_management/entries/entry.py +++ b/contentstack_management/entries/entry.py @@ -423,13 +423,15 @@ def unpublish(self, data): data = json.dumps(data) return self.client.post(url, headers = self.client.headers, data = data, params = self.params) - def variants(self, variant_uid: str = None): + def variants(self, *args): """ Returns an EntryVariants instance for managing variant entries. - - :param variant_uid: The `variant_uid` parameter is a string that represents the unique identifier of - the variant. It is used to specify which variant to work with - :type variant_uid: str + + Pass no arguments when no ``branch`` header is required, one positional argument for the + branch UID only (sent as the ``branch`` request header), or two + arguments ``(variant_uid, branch)`` for a specific variant on a branch. + + :param args: ``()`` | ``(branch,)`` | ``(variant_uid, branch)`` :return: EntryVariants instance for managing variant entries ------------------------------- [Example:] @@ -438,13 +440,26 @@ def variants(self, variant_uid: str = None): >>> client = contentstack_management.Client(authtoken='your_authtoken') >>> # Get all variant entries >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().query().find().json() - >>> # Get specific variant entry - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').fetch().json() - - ------------------------------- - """ - - return EntryVariants(self.client, self.content_type_uid, self.entry_uid, variant_uid) + >>> # Variant operations on a branch (branch header only) + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('branch_uid').query().find().json() + >>> # Specific variant on a branch + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid', 'branch_uid').fetch().json() + + ------------------------------- + """ + variant_uid = None + branch = None + if len(args) == 1: + branch = args[0] + elif len(args) == 2: + variant_uid, branch = args[0], args[1] + elif len(args) > 2: + raise TypeError( + f"variants() takes at most 2 positional arguments ({len(args)} given)" + ) + return EntryVariants( + self.client, self.content_type_uid, self.entry_uid, variant_uid, branch + ) def includeVariants(self, include_variants: str = 'true', variant_uid: str = None, params: dict = None): """ diff --git a/contentstack_management/entry_variants/entry_variants.py b/contentstack_management/entry_variants/entry_variants.py index 5dc6bb7..674ef10 100644 --- a/contentstack_management/entry_variants/entry_variants.py +++ b/contentstack_management/entry_variants/entry_variants.py @@ -1,28 +1,49 @@ """This class takes a base URL as an argument when it's initialized, which is the endpoint for the RESTFUL API that we'll be interacting with. -The query(), create(), fetch(), delete(), update(), versions(), and includeVariants() methods each correspond to +The query(), create(), fetch(), delete(), update(), versions(), includeVariants(), publish(), and unpublish() methods each correspond to the operations that can be performed on the API """ import json from ..common import Parameter from .._errors import ArgumentException -from .._messages import ENTRY_VARIANT_CONTENT_TYPE_UID_REQUIRED, ENTRY_VARIANT_ENTRY_UID_REQUIRED, ENTRY_VARIANT_UID_REQUIRED +from .._messages import ( + ENTRY_BODY_REQUIRED, + ENTRY_VARIANT_CONTENT_TYPE_UID_REQUIRED, + ENTRY_VARIANT_ENTRY_UID_REQUIRED, + ENTRY_VARIANT_UID_REQUIRED, +) class EntryVariants(Parameter): """ This class takes a base URL as an argument when it's initialized, which is the endpoint for the RESTFUL API that - we'll be interacting with. The query(), create(), fetch(), delete(), update(), versions(), and includeVariants() - methods each correspond to the operations that can be performed on the API """ + we'll be interacting with. The query(), create(), fetch(), delete(), update(), versions(), + includeVariants(), publish(), and unpublish() methods each correspond to the operations that can be + performed on the API. Optional ``branch`` scopes requests to a stack branch via the ``branch`` header. """ - def __init__(self, client, content_type_uid: str, entry_uid: str, variant_uid: str = None): + def __init__( + self, + client, + content_type_uid: str, + entry_uid: str, + variant_uid: str = None, + branch: str = None, + ): self.client = client self.content_type_uid = content_type_uid self.entry_uid = entry_uid self.variant_uid = variant_uid + self.branch = branch if branch else None super().__init__(self.client) self.path = f"content_types/{content_type_uid}/entries/{entry_uid}/variants" + def _headers(self): + """Merge optional branch into request headers without mutating the client.""" + if not self.branch: + return self.client.headers + headers = dict(self.client.headers) + headers["branch"] = self.branch + return headers def find(self, params: dict = None): """ @@ -47,7 +68,7 @@ def find(self, params: dict = None): self.validate_entry_uid() if params is not None: self.params.update(params) - return self.client.get(self.path, headers = self.client.headers, params = self.params) + return self.client.get(self.path, headers=self._headers(), params=self.params) def create(self, data: dict): """ @@ -79,7 +100,7 @@ def create(self, data: dict): self.validate_content_type_uid() self.validate_entry_uid() data = json.dumps(data) - return self.client.post(self.path, headers = self.client.headers, data=data, params = self.params) + return self.client.post(self.path, headers=self._headers(), data=data, params=self.params) def fetch(self, variant_uid: str = None, params: dict = None): """ @@ -96,9 +117,11 @@ def fetch(self, variant_uid: str = None, params: dict = None): >>> import contentstack_management >>> client = contentstack_management.Client(authtoken='your_authtoken') - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').fetch().json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().fetch('variant_uid').json() >>> # With parameters - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').fetch(params={'include_count': True}).json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().fetch('variant_uid', params={'include_count': True}).json() + >>> # On a branch + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid', 'branch_uid').fetch().json() ------------------------------- """ @@ -112,7 +135,7 @@ def fetch(self, variant_uid: str = None, params: dict = None): if params is not None: self.params.update(params) url = f"{self.path}/{self.variant_uid}" - return self.client.get(url, headers = self.client.headers, params = self.params) + return self.client.get(url, headers=self._headers(), params=self.params) def delete(self, variant_uid: str = None): """ @@ -128,7 +151,7 @@ def delete(self, variant_uid: str = None): >>> import contentstack_management >>> client = contentstack_management.Client(authtoken='your_authtoken') - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').delete().json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().delete('variant_uid').json() ------------------------------- """ @@ -138,7 +161,7 @@ def delete(self, variant_uid: str = None): self.validate_entry_uid() self.validate_variant_uid() url = f"{self.path}/{self.variant_uid}" - return self.client.delete(url, headers = self.client.headers, params = self.params) + return self.client.delete(url, headers=self._headers(), params=self.params) def update(self, data: dict, variant_uid: str = None): """ @@ -167,7 +190,7 @@ def update(self, data: dict, variant_uid: str = None): >>> } >>> import contentstack_management >>> client = contentstack_management.Client(authtoken='your_authtoken') - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').update(data).json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().update(data, 'variant_uid').json() ------------------------------- """ @@ -178,7 +201,7 @@ def update(self, data: dict, variant_uid: str = None): self.validate_variant_uid() url = f"{self.path}/{self.variant_uid}" data = json.dumps(data) - return self.client.put(url, headers = self.client.headers, data=data, params = self.params) + return self.client.put(url, headers=self._headers(), data=data, params=self.params) def versions(self, variant_uid: str = None, params: dict = None): """ @@ -195,9 +218,9 @@ def versions(self, variant_uid: str = None, params: dict = None): >>> import contentstack_management >>> client = contentstack_management.Client(authtoken='your_authtoken') - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').versions().json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().versions('variant_uid').json() >>> # With parameters - >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants('variant_uid').versions(params={'limit': 10}).json() + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().versions('variant_uid', params={'limit': 10}).json() ------------------------------- """ @@ -209,7 +232,7 @@ def versions(self, variant_uid: str = None, params: dict = None): if params is not None: self.params.update(params) url = f"{self.path}/{self.variant_uid}/versions" - return self.client.get(url, headers = self.client.headers, params = self.params) + return self.client.get(url, headers=self._headers(), params=self.params) def includeVariants(self, include_variants: str = 'true', variant_uid: str = None, params: dict = None): """ @@ -243,8 +266,85 @@ def includeVariants(self, include_variants: str = 'true', variant_uid: str = Non self.params.update(params) self.params['include_variants'] = include_variants url = f"content_types/{self.content_type_uid}/entries/{self.entry_uid}" - return self.client.get(url, headers = self.client.headers, params = self.params) + return self.client.get(url, headers=self._headers(), params=self.params) + def publish(self, data: dict): + """ + Publish the entry for this content type and entry UID (CMA ``.../entries/{entry_uid}/publish``). + + For entry variants, the body typically includes ``entry.environments``, ``entry.locales``, + ``entry.variants`` (list of ``{"uid", "version"}`` per variant), optional ``entry.variant_rules``, + and top-level ``locale`` (and optional scheduling fields supported by the API). + + :param data: Publish payload dict (serialized as JSON). + :return: Response from the publish request. + ------------------------------- + [Example:] + + >>> data = { + >>> "entry": { + >>> "environments": ["production"], + >>> "locales": ["en-us"], + >>> "variants": [ + >>> {"uid": "variant_uid", "version": 1} + >>> ], + >>> "variant_rules": { + >>> "publish_latest_base": False, + >>> "publish_latest_base_conditionally": True + >>> } + >>> }, + >>> "locale": "en-us" + >>> } + >>> import contentstack_management + >>> client = contentstack_management.Client(authtoken='your_authtoken') + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().publish(data).json() + + ------------------------------- + """ + self.validate_content_type_uid() + self.validate_entry_uid() + if data is None: + raise Exception(ENTRY_BODY_REQUIRED) + url = f"content_types/{self.content_type_uid}/entries/{self.entry_uid}/publish" + data = json.dumps(data) + return self.client.post(url, headers=self._headers(), data=data, params=self.params) + + def unpublish(self, data: dict): + """ + Unpublish the entry for this content type and entry UID (CMA ``.../entries/{entry_uid}/unpublish``). + + For entry variants, the body typically includes ``entry.environments``, ``entry.locales``, + ``entry.variants`` (list of ``{"uid", "version"}`` per variant), and top-level ``locale``. + + :param data: Unpublish payload dict (serialized as JSON). + :return: Response from the unpublish request. + ------------------------------- + [Example:] + + >>> data = { + >>> "entry": { + >>> "environments": ["environment_uid"], + >>> "locales": ["en-us"], + >>> "variants": [ + >>> {"uid": "variant_uid", "version": 1} + >>> ] + >>> }, + >>> "locale": "en-us" + >>> } + >>> import contentstack_management + >>> client = contentstack_management.Client(authtoken='your_authtoken') + >>> result = client.stack('api_key').content_types('content_type_uid').entry('entry_uid').variants().unpublish(data).json() + + ------------------------------- + """ + self.validate_content_type_uid() + self.validate_entry_uid() + if data is None: + raise Exception(ENTRY_BODY_REQUIRED) + url = f"content_types/{self.content_type_uid}/entries/{self.entry_uid}/unpublish" + data = json.dumps(data) + return self.client.post(url, headers=self._headers(), data=data, params=self.params) + def validate_content_type_uid(self): """ The function checks if the content_type_uid is None or an empty string and raises an ArgumentException diff --git a/contentstack_management/region_refresh.py b/contentstack_management/region_refresh.py new file mode 100644 index 0000000..889af34 --- /dev/null +++ b/contentstack_management/region_refresh.py @@ -0,0 +1,81 @@ +""" +Utility to pull the latest regions.json from the Contentstack CDN and +overwrite the bundled copy at contentstack_management/data/regions.json. + +Exposed as a package-level function so tooling and CI pipelines can call it +programmatically instead of invoking the script directly: + + from contentstack_management import refresh_regions + refresh_regions() +""" + +import json +import os +import sys +import urllib.request + +_REGIONS_URL = "https://artifacts.contentstack.com/regions.json" +_ASSET_PATH = os.path.join(os.path.dirname(__file__), "data", "regions.json") + + +def refresh_regions( + url: str = _REGIONS_URL, + dest: str = _ASSET_PATH, + *, + timeout: int = 30, + silent: bool = False, +) -> dict: + """ + Download the latest regions manifest from the Contentstack CDN and write + it to the bundled data file so all consumers get the update. + + @param url - URL to fetch regions.json from (defaults to Contentstack CDN) + @param dest - Destination file path (defaults to contentstack_management/data/regions.json) + @param timeout - HTTP request timeout in seconds + @param silent - Suppress progress output when True + @returns The parsed regions dict on success + @raises RuntimeError on download failure, invalid JSON, or unexpected schema + """ + dest = os.path.normpath(dest) + + if not silent: + print(f"Fetching {url} ...") + + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + data = resp.read().decode("utf-8") + except Exception as exc: + raise RuntimeError(f"Could not download regions.json: {exc}") from exc + + try: + decoded = json.loads(data) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Downloaded content is not valid JSON: {exc}") from exc + + if not isinstance(decoded, dict) or "regions" not in decoded: + raise RuntimeError("Downloaded JSON does not contain a 'regions' key.") + + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + json.dump(decoded, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + region_count = len(decoded["regions"]) + if not silent: + print(f"OK: Wrote {region_count} regions to {dest}") + + return decoded + + +def _cli_main() -> int: + """Entry point kept for backward compatibility with the scripts/ invocation.""" + try: + refresh_regions() + return 0 + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(_cli_main()) diff --git a/requirements.txt b/requirements.txt index 3f57c17..03f4a7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,12 @@ -python-dotenv>=1.0.0,<2.0.0 +python-dotenv>=1.2.2 # <82: pkg_resources (used by coverage-badge and similar tools) was removed in setuptools 82+ -setuptools>=80.0.0,<82.0.0 -requests>=2.32.4,<3.0.0 -urllib3>=2.6.3,<3.0.0 +setuptools>=83.0.0 +requests>=2.33.0,<3.0.0 +urllib3>=2.7.0,<3.0.0 pylint>=2.0.0 -bson>=0.5.9,<1.0.0 requests-toolbelt>=1.0.0,<2.0.0 pyotp==2.9.0 +packaging>=24.0 +# Integration test suite +pytest>=7.0 +pytest-order>=1.2.0 diff --git a/scripts/download_regions.py b/scripts/download_regions.py new file mode 100644 index 0000000..3a78301 --- /dev/null +++ b/scripts/download_regions.py @@ -0,0 +1,68 @@ +""" +Downloads the Contentstack regions registry from the official source and +saves it to contentstack_management/data/regions.json. + +Run manually: + python3 scripts/download_regions.py +""" + +import json +import os +import sys +import requests + +REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + +DEST = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'contentstack_management', 'data', 'regions.json' +) + + +def download(): + dest_dir = os.path.dirname(DEST) + os.makedirs(dest_dir, exist_ok=True) + + print(f'contentstack-management: Downloading regions.json from {REGIONS_URL} ...') + + try: + response = requests.get(REGIONS_URL, timeout=30) + response.raise_for_status() + data = response.text + except Exception as exc: + sys.stderr.write( + f'contentstack-management: Warning — could not download regions.json: {exc}. ' + 'The SDK will attempt to download it at runtime on first use.\n' + ) + sys.exit(0) + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + sys.stderr.write( + 'contentstack-management: Warning — downloaded data is not valid JSON.\n' + ) + sys.exit(0) + + if not isinstance(decoded, dict) or 'regions' not in decoded or \ + not isinstance(decoded['regions'], list): + sys.stderr.write( + 'contentstack-management: Warning — downloaded data is not a valid regions.json.\n' + ) + sys.exit(0) + + try: + with open(DEST, 'w', encoding='utf-8') as f: + f.write(data) + except OSError as exc: + sys.stderr.write( + f'contentstack-management: Warning — could not write regions.json to {DEST}: {exc}\n' + ) + sys.exit(0) + + region_count = len(decoded['regions']) + print(f'contentstack-management: regions.json downloaded ({region_count} regions) → {DEST}') + + +if __name__ == '__main__': + download() diff --git a/setup.py b/setup.py index d6e158f..1061207 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,21 @@ import os import re +import sys from setuptools import setup, find_packages +from setuptools.command.build_py import build_py + + +class BuildPyWithRegions(build_py): + """Fetch latest regions.json from Contentstack CDN before packaging.""" + + def run(self): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + try: + from contentstack_management.region_refresh import refresh_regions + refresh_regions() + except Exception as exc: + print(f"WARNING: Could not refresh regions.json: {exc}", file=sys.stderr) + super().run() with open("README.md", "r") as f: long_description = f.read() @@ -33,9 +48,11 @@ def get_author_email(package): init_py, re.MULTILINE).group(1) setup( + cmdclass={"build_py": BuildPyWithRegions}, name="contentstack-management", version=get_version(package), packages=find_packages(exclude=['tests']), + package_data={'contentstack_management': ['data/regions.json']}, py_modules=['_api_client', 'contentstack','common','_errors','_constant'], description="Contentstack API Client Library for Python", long_description=long_description, @@ -50,13 +67,12 @@ def get_author_email(package): "Operating System :: OS Independent", ], install_requires=[ - "bson >= 0.5.9", "requests >= 2.5.4", "requests-toolbelt >= 0.3.1", - "urllib3 >= 2.6.3", + "urllib3 >= 2.7.0,<3.0.0", ], extras_require={ - "dev": ["pytest>=7.0", "twine>=4.0.2", "dotenv>=0.0.5"], + "dev": ["pytest>=7.0", "twine>=4.0.2", "packaging>=24.0", "dotenv>=0.0.5"], }, python_requires=">=3.9", ) diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..ef2eea6 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,16 @@ +# Skills – Contentstack Management Python + +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** | Install, **`pytest`** (unit / API / mock), **`__version__`**, pylint, Talisman/Snyk, before PR | +| **contentstack-management** | **`Client`**, **`Stack`**, **`_APIClient`**, domain modules, OAuth, CMA paths and payloads | +| **python-style** | Editing **`contentstack_management/`** or **`setup.py`** / **`requirements.txt`**—layout, style, imports | +| **testing** | Adding or changing tests under **`tests/`**, **`tests/cred.py`**, env for API runs | +| **code-review** | PR checklist, API semver, HTTP regressions, secrets | +| **framework** | Changing **`_APIClient`**, retries, **`requests`** usage, OAuth interceptor wiring | + +Each folder contains `SKILL.md` with YAML frontmatter (`name`, `description`). diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 0000000..b8e2bb4 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,44 @@ +--- +name: code-review +description: Use when reviewing PRs for the Management Python SDK — public API, _APIClient, OAuth, tests, security. +--- + +# Code review – Contentstack Management Python + +## When to use + +- Reviewing a PR, self-review before submit, or automated review prompts. + +## Instructions + +Work through the checklist below. Optionally tag findings: **Blocker**, **Major**, **Minor**. + +### Public API + +- [ ] **Exported** **`Client`**, **`Region`**, stack and resource helpers match **README** and **`contentstack_management.__all__`** / **`__init__.py`**. +- [ ] **Docstrings** on **`Client`** and changed public methods when behavior or parameters change. + +### Compatibility + +- [ ] Avoid breaking **`Client`** constructor or stack method chains without a semver strategy; document migration for breaking changes. + +### HTTP / auth + +- [ ] Changes to **`_APIClient`** or **OAuth** paths: verify retries, headers, and interceptor behavior with unit tests; no regressions for **authtoken** / **management_token** headers. + +### Testing + +- [ ] **Unit** coverage for new logic; **API** updates when live CMA request/response behavior changes; **mock** when contract-style tests are appropriate. +- [ ] **`pytest tests/unit/`** passes. + +### Security + +- [ ] No hardcoded tokens; no logging secrets in new code. + +### Severity (optional) + +| Level | Examples | +|-------|----------| +| **Blocker** | Breaking public API without approval; security issue; no tests for new logic where tests are practical | +| **Major** | Inconsistent HTTP/auth behavior; README examples that do not match code | +| **Minor** | Style; minor docs | diff --git a/skills/contentstack-management/SKILL.md b/skills/contentstack-management/SKILL.md new file mode 100644 index 0000000..4cb5285 --- /dev/null +++ b/skills/contentstack-management/SKILL.md @@ -0,0 +1,39 @@ +--- +name: contentstack-management +description: Use for Client, Stack, _APIClient, domain resources, OAuth, and CMA API alignment. +--- + +# Contentstack Management SDK – Contentstack Management Python + +## When to use + +- Implementing or changing **`Client`**, **Stack**, or resource modules (entries, assets, webhooks, …). +- Updating **`README.md`** or public exports for user-visible behavior. +- Assessing semver impact of constructor, method, or export changes. + +## Instructions + +### Main entry (consumer API) + +- **`contentstack_management.Client`** in **`contentstack.py`**: builds CMA **endpoint** from **region** / **host** / **scheme**, merges **headers** (**authtoken**, **management_token**, **early_access**), constructs **`_APIClient`**, optional **`oauth_config`** with **`OAuthHandler`**. + +### Structure + +- **`Stack`** — **`contentstack_management/stack/stack.py`**: stack-scoped resources (content types, entries, assets, branches, webhooks, …). +- **Org / user** — **`organizations/`**, **`users/`**, **`user_session/`** as applicable. +- **Resources** — packages under **`contentstack_management/`** following existing patterns. +- **OAuth** — **`oauth/oauth_handler.py`**, **`oauth/oauth_interceptor.py`**; keep aligned with **`_APIClient`** request path. + +### HTTP layer + +- **`_APIClient._call_request`** — central place for method, URL, JSON, files; respect **timeout** and **max_retries**. + +### Extending + +- Add methods on the appropriate resource class; align path and payload shapes with **CMA** docs. +- Prefer routing HTTP through **`_APIClient`** for consistent retries and OAuth handling. + +### Docs and versioning + +- Exported **`Client`**, **`Region`**, and stack helpers should match **README** and **`contentstack_management.__init__.py`**. +- Document migration for intentional breaking changes. diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000..0762bcd --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,40 @@ +--- +name: dev-workflow +description: Use for install, pytest suites, versioning, pylint, hooks, and PR baseline in contentstack-management-python. +--- + +# Development workflow – Contentstack Management Python + +## When to use + +- Setting up locally, opening a PR, or matching CI expectations. +- Answering "how do we run tests?" or "what runs in CI?" + +## Instructions + +### Branches & releases + +- **Flow:** merge work to **`development`**; **release PRs** are **`development` → `main`**. After **`main`** moves, [`.github/workflows/back-merge-pr.yml`](../../.github/workflows/back-merge-pr.yml) can open **`main` → `development`** to resync. +- **PyPI:** publish a **GitHub Release** (`release: published`) to run [`.github/workflows/release.yml`](../../.github/workflows/release.yml). PRs that touch **`contentstack_management/`** or **`setup.py`** must bump **`__version__`** and **`CHANGELOG.md`** per [`.github/workflows/check-version-bump.yml`](../../.github/workflows/check-version-bump.yml). + +### Before a PR + +1. **Install** — `pip install -e ".[dev]"` or install **`requirements.txt`** plus **pytest** / **pytest-cov** as needed. +2. **`pytest tests/unit/`** — required baseline (matches CI **`coverage run -m pytest tests/unit/`**). +3. **API tests** — `pytest tests/api/` when your change affects live CMA behavior; configure **`.env`** per **`tests/cred.py`**. Never commit tokens. +4. **Mock tests** — `pytest tests/mock/` when extending mocked HTTP or fixtures. + +### Packaging + +- Bump **`contentstack_management/__init__.py`** **`__version__`** and align **`setup.py`** versioning if release-facing. + +### Tooling + +- **pylint** is listed in **`requirements.txt`**; follow existing style in touched files. +- **Husky / Talisman / Snyk** — see **README.md** for local hook setup. + +### Pull requests + +- Build passes: **`pytest tests/unit/`** at minimum; run **API** / **mock** when your change touches those layers. +- Follow **`skills/code-review/SKILL.md`** before merge. +- Prefer backward-compatible public API; call out breaking changes and semver. diff --git a/skills/framework/SKILL.md b/skills/framework/SKILL.md new file mode 100644 index 0000000..8f44ff2 --- /dev/null +++ b/skills/framework/SKILL.md @@ -0,0 +1,27 @@ +--- +name: framework +description: Use for _APIClient, requests usage, retries, timeout, and OAuth interceptor in contentstack-management-python. +--- + +# Framework / HTTP – Contentstack Management Python + +## When to use + +- Editing **`contentstack_management/_api_client.py`** or OAuth interceptor/handler wiring. +- Changing retry policy, timeouts, or how **`requests`** is invoked. + +## Instructions + +### Integration point + +- **`_APIClient`** uses **`requests`** in **`_call_request`**, honors **timeout** and **max_retries**, and delegates to **`oauth_interceptor`** when configured. + +### When to change + +- **Retry or transport behavior** — keep logic centralized in **`_APIClient`** unless a resource truly needs a documented exception. +- **Auth headers** — prefer extending **`Client`** / **`user_agents`** patterns rather than scattering header merges. + +### Testing + +- **Unit** — mock **`requests`** or **`_APIClient`** at the boundary used by existing tests. +- **API** — full stack via credentials from **`tests/cred.py`**. diff --git a/skills/python-style/SKILL.md b/skills/python-style/SKILL.md new file mode 100644 index 0000000..d91ed70 --- /dev/null +++ b/skills/python-style/SKILL.md @@ -0,0 +1,33 @@ +--- +name: python-style +description: Use for package layout under contentstack_management/, setup.py, pylint-friendly style, and imports. +--- + +# Python style and repo layout – Contentstack Management Python + +## When to use + +- Editing any Python under **`contentstack_management/`**, **`setup.py`**, or **`requirements.txt`**. +- Adding modules or changing how the public package surface is exported. + +## Instructions + +### Layout + +- **`contentstack_management/contentstack.py`** — **`Client`**, **`Region`**, **`user_agents`**, OAuth wiring. +- **`contentstack_management/_api_client.py`** — **`_APIClient`** (HTTP, retries). +- **`contentstack_management/stack/`** — stack-scoped API surface. +- **Domain modules** — **`entries/`**, **`assets/`**, **`webhooks/`**, **`oauth/`**, etc. + +### Style + +- Match existing modules: naming, docstrings, and patterns already used in the same directory. +- Prefer small, focused changes; keep **`__init__.py`** exports consistent with public API intent (**`README`**, **`contentstack_management.__all__`**). + +### Imports + +- Use **`requests`** (and **`requests-toolbelt`** where already used) through **`_APIClient`** patterns rather than ad-hoc clients in domain modules unless justified. + +### Security + +- Do not log **authtokens**, **management tokens**, **passwords**, or **API keys**; preserve existing header handling in **`Client`**. diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..9b7fca0 --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,39 @@ +--- +name: testing +description: Use for pytest unit/API/mock suites, tests/cred.py, .env setup, and hygiene in contentstack-management-python. +--- + +# Testing – Contentstack Management Python + +## When to use + +- Adding or changing tests under **`tests/`**. +- Debugging API vs mock failures; improving **`tests/cred.py`** usage. + +## Instructions + +### Runner and tooling + +| Suite | Path | Notes | +|-------|------|--------| +| **Unit** | `tests/unit/**` | Fast, isolated; primary CI target | +| **API** | `tests/api/**` | Live CMA — **`.env`** via **`tests/cred.py`** | +| **Mock** | `tests/mock/**` | Mocked HTTP / contracts | + +| Goal | Command | +|------|---------| +| Unit (CI-style) | `pytest tests/unit/ -v` or `coverage run -m pytest tests/unit/` | +| API (live CMA) | `pytest tests/api/ -v` | +| Mock | `pytest tests/mock/ -v` | +| Full tree | `pytest tests/ -v` | + +### Environment (tests/cred.py) + +- **`get_credentials()`** loads **dotenv** and returns host, tokens, and resource UIDs. +- Common vars: **`HOST`**, **`APIKEY`**, **`AUTHTOKEN`**, **`MANAGEMENT_TOKEN`**, **`ORG_UID`**, plus resource UIDs as tests require. +- Use a **`.env`** at repo root for local API runs; never commit secrets. + +### Hygiene + +- No committed secrets; use placeholders or env-only values for CI. +- Avoid leaving **`pytest.skip`** or focused-only tests enabled in paths meant for full suite runs unless intentional. diff --git a/tests/integration/api/test_01_user.py b/tests/integration/api/test_01_user.py new file mode 100644 index 0000000..0a545d7 --- /dev/null +++ b/tests/integration/api/test_01_user.py @@ -0,0 +1,42 @@ +"""User API tests — profile fetch and update.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(1) + + +class TestUser: + def test_fetch(self, ctx): + resp = ctx.client.user().fetch() + h.assert_status(resp, 200) + user = h.body(resp).get("user", {}) + h.tracked_assert(user.get("uid"), "user uid").truthy() + + def test_update_noop(self, ctx): + # Send the current first_name back — a harmless update that exercises PUT /user. + current = h.body(ctx.client.user().fetch()).get("user", {}) + payload = {"user": {"first_name": current.get("first_name", "Test")}} + resp = ctx.client.user().update(payload) + h.assert_status(resp, 200, 201) + + +class TestUserAuthOps: + """Account auth endpoints exercised safely (bogus tokens / non-real email).""" + + def test_activate_bogus_token(self, ctx): + resp = ctx.client.user().activate("bogus_activation_token", {"user": {"password": "Test@12345"}}) + h.assert_status(resp, 400, 404, 422) + + def test_reset_password_bogus_token(self, ctx): + resp = ctx.client.user().reset_password( + {"user": {"reset_password_token": "bogus", "password": "Test@12345", "password_confirmation": "Test@12345"}} + ) + h.assert_status(resp, 400, 404, 422) + + def test_forgot_password(self, ctx): + # Triggers a reset email to a non-real address; APIs typically return 200 + # regardless (to avoid email enumeration) or a 422. + resp = ctx.client.user().forgot_password({"user": {"email": "noreply+test@example.com"}}) + h.assert_status(resp, 200, 201, 422, 429) diff --git a/tests/integration/api/test_02_organization.py b/tests/integration/api/test_02_organization.py new file mode 100644 index 0000000..a199945 --- /dev/null +++ b/tests/integration/api/test_02_organization.py @@ -0,0 +1,50 @@ +"""Organization API tests — fetch, roles, stacks, logs, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(2) + + +class TestOrganization: + def test_find_all(self, ctx): + resp = ctx.client.organizations().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("organizations"), "orgs list").is_type(list) + + def test_fetch(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).fetch() + h.assert_status(resp, 200) + org = h.body(resp).get("organization", {}) + h.tracked_assert(org.get("uid"), "org uid").equals(ctx.organization_uid) + + def test_roles(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).roles() + h.assert_status(resp, 200) + + def test_stacks(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).stacks() + h.assert_status(resp, 200) + + def test_logs(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).logs() + h.assert_status(resp, 200) + + +class TestOrganizationOwnership: + """Exercised safely with invalid data so no real invite/transfer occurs.""" + + def test_add_users_invalid(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).add_users({"share": {}}) + h.assert_status(resp, 400, 403, 422) + + def test_transfer_ownership_invalid(self, ctx): + resp = ctx.client.organizations(ctx.organization_uid).transfer_ownership({"transfer_to": "not-an-email"}) + h.assert_status(resp, 400, 403, 422) + + +class TestOrganizationNegative: + def test_fetch_nonexistent(self, ctx): + resp = ctx.client.organizations("org_does_not_exist").fetch() + h.assert_status(resp, 404, 422, 403) diff --git a/tests/integration/api/test_03_stack.py b/tests/integration/api/test_03_stack.py new file mode 100644 index 0000000..83e252c --- /dev/null +++ b/tests/integration/api/test_03_stack.py @@ -0,0 +1,93 @@ +"""Stack API tests — fetch, settings, users, share/unshare.""" + +import os + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(3) + + +class TestStack: + def test_fetch(self, ctx): + resp = ctx.client.stack(ctx.stack_api_key).fetch() + h.assert_status(resp, 200) + stack = h.body(resp).get("stack", {}) + h.tracked_assert(stack.get("api_key"), "api_key").equals(ctx.stack_api_key) + + def test_settings(self, ctx): + resp = ctx.client.stack(ctx.stack_api_key).settings() + h.assert_status(resp, 200) + + def test_create_settings(self, ctx): + data = { + "stack_settings": { + "stack_variables": {"enforce_unique_urls": "true"}, + } + } + resp = ctx.client.stack(ctx.stack_api_key).create_settings(data) + h.assert_status(resp, 200, 201) + + def test_users(self, ctx): + resp = ctx.client.stack(ctx.stack_api_key).users() + h.assert_status(resp, 200) + + def test_update(self, ctx): + data = {"stack": {"description": "updated by integration suite"}} + resp = ctx.client.stack(ctx.stack_api_key).update(data) + h.assert_status(resp, 200, 201) + + def test_reset_settings(self, ctx): + resp = ctx.client.stack(ctx.stack_api_key).reset_settings({"stack_settings": {}}) + h.assert_status(resp, 200, 201) + + +class TestStackOwnership: + """Ownership/role operations exercised safely (no real transfer occurs).""" + + def test_update_user_role(self, ctx): + # Map the current user to a role; on a fresh single-user stack this may + # be accepted (200) or rejected (422) — both confirm the SDK call works. + roles = h.body(ctx.client.stack(ctx.stack_api_key).roles().find()).get("roles", []) + role_uid = next((r["uid"] for r in roles), None) + if not (role_uid and ctx.user_uid): + pytest.skip("no role/user available") + resp = ctx.client.stack(ctx.stack_api_key).update_user_role({"users": {ctx.user_uid: [role_uid]}}) + # 404 when the user isn't a separately-added stack member (owner self-assign). + h.assert_status(resp, 200, 201, 404, 422) + + def test_transfer_ownership_invalid(self, ctx): + # Transferring to an invalid address must fail — exercises the endpoint + # without actually handing the stack to anyone. + resp = ctx.client.stack(ctx.stack_api_key).transfer_ownership({"transfer_to": "not-an-email"}) + h.assert_status(resp, 400, 422) + + def test_accept_ownership_bogus_token(self, ctx): + # Accepting with a bogus token must fail. + resp = ctx.client.stack(ctx.stack_api_key).accept_ownership(ctx.user_uid or "uid", "bogus_token") + h.assert_status(resp, 400, 404, 422) + + +class TestStackSharing: + def test_share(self, ctx): + member = os.getenv("MEMBER_EMAIL") + if not member: + pytest.skip("MEMBER_EMAIL not set") + # Sharing requires a valid role mapping per email — an empty roles object + # is rejected with 422 "roles is not valid". + roles = h.body(ctx.client.stack(ctx.stack_api_key).roles().find()).get("roles", []) + role_uid = next((r["uid"] for r in roles if r.get("name") != "Admin"), + roles[0]["uid"] if roles else None) + if not role_uid: + pytest.skip("no role available to share with") + data = {"emails": [member], "roles": {member: [role_uid]}} + resp = ctx.client.stack(ctx.stack_api_key).share(data) + h.assert_status(resp, 200, 201) + + def test_unshare(self, ctx): + member = os.getenv("MEMBER_EMAIL") + if not member: + pytest.skip("MEMBER_EMAIL not set") + resp = ctx.client.stack(ctx.stack_api_key).unshare({"email": member}) + h.assert_status(resp, 200, 201) diff --git a/tests/integration/api/test_04_locale.py b/tests/integration/api/test_04_locale.py new file mode 100644 index 0000000..e977a60 --- /dev/null +++ b/tests/integration/api/test_04_locale.py @@ -0,0 +1,64 @@ +"""Locale API tests — CRUD, fallback, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(4) + +# A non-master locale code to create/manipulate. +_CODE = "fr-fr" + + +class TestLocaleCRUD: + def test_create(self, stack, store): + data = {"locale": {"name": "French", "code": _CODE}} + resp = stack.locale().create(data) + h.assert_status(resp, 201) + store["locales"]["custom"] = _CODE + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.locale().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("locales"), "locales list").is_type(list) + + def test_fetch(self, stack): + resp = stack.locale(_CODE).fetch() + h.assert_status(resp, 200) + h.validate_locale_response(resp) + + def test_update(self, stack): + data = {"locale": {"name": "French (FR)"}} + resp = stack.locale(_CODE).update(data) + h.assert_status(resp, 200, 201) + + def test_set_fallback(self, stack): + data = {"locale": {"name": "German", "code": "de-de", "fallback_locale": "en-us"}} + resp = stack.locale().set_fallback(data) + h.assert_status(resp, 200, 201) + + def test_update_fallback(self, stack): + # Ensure de-de exists, then update its fallback configuration. + stack.locale().create({"locale": {"name": "German", "code": "de-de"}}) + h.wait(h.SHORT_DELAY) + data = {"locale": {"name": "German", "code": "de-de", "fallback_locale": "en-us"}} + resp = stack.locale("de-de").update_fallback(data) + h.assert_status(resp, 200, 201) + + +class TestLocaleNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.locale("zz-zz").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_code_raises(self, stack): + # Locale guards on a missing locale_code before the HTTP call. + with pytest.raises(Exception): + stack.locale().fetch() + + +class TestLocaleDelete: + def test_delete(self, stack): + resp = stack.locale("de-de").delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_05_environment.py b/tests/integration/api/test_05_environment.py new file mode 100644 index 0000000..6e5c928 --- /dev/null +++ b/tests/integration/api/test_05_environment.py @@ -0,0 +1,55 @@ +"""Environment API tests — CRUD, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(5) + + +class TestEnvironmentCRUD: + def test_create(self, stack, store): + name = h.generate_valid_uid("env") + data = {"environment": {"name": name, "urls": [{"url": "https://example.com", "locale": "en-us"}]}} + resp = stack.environments().create(data) + h.assert_status(resp, 201) + store["environments"]["main"] = name + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.environments().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("environments"), "env list").is_type(list) + + def test_fetch(self, stack, store): + name = store["environments"]["main"] + resp = stack.environments(name).fetch() + h.assert_status(resp, 200) + h.validate_environment_response(resp) + + def test_update(self, stack, store): + name = store["environments"]["main"] + data = {"environment": {"name": name, "urls": [{"url": "https://updated.example.com", "locale": "en-us"}]}} + resp = stack.environments(name).update(data) + h.assert_status(resp, 200, 201) + + +class TestEnvironmentNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.environments("does_not_exist_env").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_name_raises(self, stack): + with pytest.raises(Exception): + stack.environments().fetch() + + +class TestEnvironmentDelete: + def test_delete(self, stack): + name = h.generate_valid_uid("env_del") + stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://d.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + resp = stack.environments(name).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_06_asset.py b/tests/integration/api/test_06_asset.py new file mode 100644 index 0000000..54f5dfe --- /dev/null +++ b/tests/integration/api/test_06_asset.py @@ -0,0 +1,229 @@ +""" +Asset API tests — full method coverage: upload/CRUD, versions, folders, publishing, +RTE, type filters, and negative/edge cases. + +Independent of content types. Uses a real PNG under data/assets/. +""" + +import json +import os + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(6) + +_ASSET_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets", "sample.png") + + +@pytest.fixture(scope="class") +def environment_name(stack, store): + name = store.get("environments", {}).get("main") + if name: + return name + name = h.generate_valid_uid("env_asset") + stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + store.setdefault("environments", {})["main"] = name + return name + + +class TestAssetCRUD: + def test_upload(self, stack, store): + resp = stack.assets().upload(_ASSET_PATH) + h.assert_status(resp, 201) + data = h.validate_asset_response(resp) + store["assets"]["main"] = data["uid"] + h.wait(h.SHORT_DELAY) + + def test_fetch(self, stack, store): + resp = stack.assets(store["assets"]["main"]).fetch() + h.assert_status(resp, 200) + h.validate_asset_response(resp) + + def test_find_all(self, stack): + resp = stack.assets().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("assets"), "assets list").is_type(list) + + def test_update_title(self, stack, store): + resp = stack.assets(store["assets"]["main"]).update({"asset": {"title": "Updated Asset"}}) + h.assert_status(resp, 200, 201) + + def test_replace(self, stack, store): + resp = stack.assets(store["assets"]["main"]).replace(_ASSET_PATH) + h.assert_status(resp, 200, 201) + + def test_references(self, stack, store): + resp = stack.assets(store["assets"]["main"]).references() + h.assert_status(resp, 200) + + +class TestAssetVersions: + def test_version_naming(self, stack, store): + resp = stack.assets(store["assets"]["main"]).version_naming(1, {"upload": {"_version_name": "v1"}}) + h.assert_status(resp, 200, 201) + + def test_versions(self, stack, store): + resp = stack.assets(store["assets"]["main"]).version() + h.assert_status(resp, 200) + + def test_version_delete(self, stack, store): + resp = stack.assets(store["assets"]["main"]).version_delete(1) + h.assert_status(resp, 200) + + def test_update_asset_revision(self, stack, store): + resp = stack.assets(store["assets"]["main"]).update_asset_revision( + {"asset": {"title": "Revised"}, "version": 1} + ) + h.assert_status(resp, 200, 201) + + def test_generate_permanent_url(self, stack, store): + asset_uid = store["assets"]["main"] + host = os.getenv("HOST", "api.contentstack.io") + api_key = stack.client.headers.get("api_key") + resp = stack.assets(asset_uid).generate( + {"asset": {"permanent_url": f"https://{host}/v3/assets/{api_key}/{asset_uid}/slug.png"}} + ) + h.assert_status(resp, 200, 201) + + def test_download_without_slug_is_404(self, stack, store): + # download() builds assets/{api_key}/{uid} without a slug, so the permanent- + # URL endpoint returns 404 for a normally-uploaded asset. + resp = stack.assets(store["assets"]["main"]).download() + h.assert_status(resp, 404) + + +class TestAssetTypesAndRte: + def test_rte(self, stack): + resp = stack.assets().rte() + h.assert_status(resp, 200) + + def test_specific_asset_type_images(self, stack): + resp = stack.assets().specific_asset_type("images") + h.assert_status(resp, 200) + + +class TestAssetFolders: + def test_create_folder(self, stack, store): + resp = stack.assets().create_folder(json.dumps({"asset": {"name": h.generate_unique_title("Folder")}})) + h.assert_status(resp, 201) + store["folders"]["main"] = h.body(resp).get("asset", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_folder(self, stack, store): + resp = stack.assets().folder(store["folders"]["main"]) + h.assert_status(resp, 200) + + def test_specific_folder(self, stack, store): + resp = stack.assets().specific_folder(store["folders"]["main"]) + h.assert_status(resp, 200) + + def test_subfolders(self, stack, store): + resp = stack.assets().subfolders(store["folders"]["main"]) + h.assert_status(resp, 200) + + def test_get_subfolders(self, stack, store): + resp = stack.assets().get_subfolders(store["folders"]["main"]) + h.assert_status(resp, 200) + + def test_folder_by_name(self, stack): + resp = stack.assets().folder_by_name() + h.assert_status(resp, 200) + + def test_update_or_move(self, stack, store): + resp = stack.assets().update_or_move( + store["folders"]["main"], {"asset": {"name": h.generate_unique_title("Folder2")}} + ) + h.assert_status(resp, 200, 201) + + def test_delete_folder(self, stack, store): + folder_uid = store.get("folders", {}).get("main") + if not folder_uid: + pytest.skip("folder was not created") + resp = stack.assets().delete_folder(folder_uid) + h.assert_status(resp, 200) + + +class TestAssetPublish: + def test_publish(self, stack, store, environment_name): + resp = stack.assets(store["assets"]["main"]).publish( + {"asset": {"locales": ["en-us"], "environments": [environment_name]}, "version": 1} + ) + h.assert_status(resp, 200, 201) + h.wait(h.SHORT_DELAY) + + def test_unpublish(self, stack, store, environment_name): + resp = stack.assets(store["assets"]["main"]).unpublish( + {"asset": {"locales": ["en-us"], "environments": [environment_name]}, "version": 1} + ) + h.assert_status(resp, 200, 201) + + +class TestAssetNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.assets("does_not_exist_uid").fetch() + h.assert_status(resp, 404, 422) + h.validate_error_body(resp) + + def test_specific_asset_type_requires_type(self, stack): + with pytest.raises(Exception): + stack.assets().specific_asset_type(None) + + +class TestAssetScan: + """Asset scanning (AM 2.0) — verified enabled on the normal ORGANIZATION too. + + The scan status is exposed only when include_asset_scan_status=true is passed; + the response field is _asset_scan_status with values pending -> clean | quarantined. + """ + + def test_upload_returns_pending(self, stack): + asset = stack.assets() + asset.add_param("include_asset_scan_status", "true") + resp = asset.upload(_ASSET_PATH) + h.assert_status(resp, 201) + status = h.body(resp).get("asset", {}).get("_asset_scan_status") + h.tracked_assert(status, "scan status on upload").equals("pending") + + def test_scan_status_absent_without_param(self, stack): + # The field must be absent unless the include param is passed. + created = h.body(stack.assets().upload(_ASSET_PATH)).get("asset", {}) + resp = stack.assets(created["uid"]).fetch() + h.assert_status(resp, 200) + h.tracked_assert( + "_asset_scan_status" not in h.body(resp).get("asset", {}), "field absent w/o param" + ).equals(True) + + def test_clean_asset_scanned_clean(self, stack): + created = h.body(stack.assets().upload(_ASSET_PATH)).get("asset", {}) + status = h.wait_for_scan(stack, created["uid"], "clean") + h.tracked_assert(status, "clean file scan result").equals("clean") + + def test_malware_asset_quarantined(self, stack, eicar_file): + created = h.body(stack.assets().upload(eicar_file)).get("asset", {}) + status = h.wait_for_scan(stack, created["uid"], "quarantined") + h.tracked_assert(status, "EICAR scan result").equals("quarantined") + + def test_find_includes_scan_status(self, stack): + query = stack.assets() + query.add_param("include_asset_scan_status", "true") + resp = query.find() + h.assert_status(resp, 200) + assets = h.body(resp).get("assets", []) + if assets: + h.tracked_assert( + "_asset_scan_status" in assets[0], "scan status in listing" + ).equals(True) + + +class TestAssetDelete: + def test_delete(self, stack): + created = h.body(stack.assets().upload(_ASSET_PATH)) + asset_uid = created.get("asset", {}).get("uid") + h.wait(h.SHORT_DELAY) + resp = stack.assets(asset_uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_07_taxonomy.py b/tests/integration/api/test_07_taxonomy.py new file mode 100644 index 0000000..7314f11 --- /dev/null +++ b/tests/integration/api/test_07_taxonomy.py @@ -0,0 +1,52 @@ +"""Taxonomy API tests — CRUD, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(7) + + +class TestTaxonomyCRUD: + def test_create(self, stack, store): + uid = h.generate_valid_uid("tax") + data = {"taxonomy": {"uid": uid, "name": f"Taxonomy {uid}", "description": "test"}} + resp = stack.taxonomy().create(data) + h.assert_status(resp, 201) + store["taxonomies"]["main"] = uid + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.taxonomy().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store["taxonomies"]["main"] + resp = stack.taxonomy(uid).fetch() + h.assert_status(resp, 200) + h.validate_taxonomy_response(resp) + + def test_update(self, stack, store): + uid = store["taxonomies"]["main"] + data = {"taxonomy": {"name": f"Updated {uid}"}} + resp = stack.taxonomy(uid).update(data) + h.assert_status(resp, 200, 201) + + +class TestTaxonomyNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.taxonomy("does_not_exist_tax").fetch() + h.assert_status(resp, 404, 422) + + +class TestTaxonomyDelete: + def test_delete(self, stack): + uid = h.generate_valid_uid("tax_del") + stack.taxonomy().create({"taxonomy": {"uid": uid, "name": f"Del {uid}"}}) + h.wait(h.SHORT_DELAY) + # The CMA API rejects a body-less DELETE that carries Content-Type: + # application/json (the Python SDK merges it by default; the JS SDK/axios + # omits it). Drop it before the call so the delete succeeds (204). + stack.client.headers.pop("Content-Type", None) + resp = stack.taxonomy(uid).delete() + h.assert_status(resp, 200, 204) diff --git a/tests/integration/api/test_08_terms.py b/tests/integration/api/test_08_terms.py new file mode 100644 index 0000000..ca067ad --- /dev/null +++ b/tests/integration/api/test_08_terms.py @@ -0,0 +1,89 @@ +"""Terms API tests — CRUD within a taxonomy, search, hierarchy, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(8) + + +@pytest.fixture(scope="class") +def taxonomy_uid(stack, store): + uid = store.get("taxonomies", {}).get("main") + if uid: + return uid + uid = h.generate_valid_uid("tax_terms") + stack.taxonomy().create({"taxonomy": {"uid": uid, "name": f"Tax {uid}"}}) + h.wait(h.SHORT_DELAY) + store.setdefault("taxonomies", {})["main"] = uid + return uid + + +class TestTermsCRUD: + def test_create(self, stack, store, taxonomy_uid): + uid = h.generate_valid_uid("term") + data = {"term": {"uid": uid, "name": f"Term {uid}"}} + resp = stack.taxonomy(taxonomy_uid).terms().create(data) + h.assert_status(resp, 201) + store["terms"]["main"] = uid + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack, taxonomy_uid): + resp = stack.taxonomy(taxonomy_uid).terms().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store, taxonomy_uid): + uid = store["terms"]["main"] + resp = stack.taxonomy(taxonomy_uid).terms(uid).fetch() + h.assert_status(resp, 200) + h.validate_term_response(resp) + + def test_update(self, stack, store, taxonomy_uid): + uid = store["terms"]["main"] + data = {"term": {"name": f"Updated {uid}"}} + resp = stack.taxonomy(taxonomy_uid).terms(uid).update(data) + h.assert_status(resp, 200, 201) + + def test_descendants(self, stack, store, taxonomy_uid): + uid = store["terms"]["main"] + resp = stack.taxonomy(taxonomy_uid).terms(uid).descendants() + h.assert_status(resp, 200) + + def test_ancestors(self, stack, store, taxonomy_uid): + uid = store["terms"]["main"] + resp = stack.taxonomy(taxonomy_uid).terms(uid).ancestors() + h.assert_status(resp, 200) + + def test_search(self, stack, taxonomy_uid): + resp = stack.taxonomy(taxonomy_uid).terms().search("Term") + h.assert_status(resp, 200) + + def test_move(self, stack, store, taxonomy_uid): + # Create a child term and reparent it under the main term. The move payload + # requires the term's name alongside the new parent_uid. + child = h.generate_valid_uid("term_child") + name = f"Child {child}" + stack.taxonomy(taxonomy_uid).terms().create({"term": {"uid": child, "name": name}}) + h.wait(h.SHORT_DELAY) + parent = store["terms"]["main"] + resp = stack.taxonomy(taxonomy_uid).terms(child).move( + {"term": {"uid": child, "name": name, "parent_uid": parent}} + ) + h.assert_status(resp, 200, 201) + + +class TestTermsNegative: + def test_fetch_nonexistent(self, stack, taxonomy_uid): + resp = stack.taxonomy(taxonomy_uid).terms("no_such_term").fetch() + h.assert_status(resp, 404, 422) + + +class TestTermsDelete: + def test_delete(self, stack, taxonomy_uid): + uid = h.generate_valid_uid("term_del") + stack.taxonomy(taxonomy_uid).terms().create({"term": {"uid": uid, "name": f"Del {uid}"}}) + h.wait(h.SHORT_DELAY) + # Drop Content-Type for the body-less DELETE (see taxonomy delete note). + stack.client.headers.pop("Content-Type", None) + resp = stack.taxonomy(taxonomy_uid).terms(uid).delete() + h.assert_status(resp, 200, 204) diff --git a/tests/integration/api/test_09_extension.py b/tests/integration/api/test_09_extension.py new file mode 100644 index 0000000..15fa45a --- /dev/null +++ b/tests/integration/api/test_09_extension.py @@ -0,0 +1,83 @@ +"""Extension API tests — create (custom field), upload (HTML widget), CRUD, negatives.""" + +import os + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(9) + +_EXT_HTML = os.path.join(os.path.dirname(__file__), "..", "data", "assets", "extension.html") + + +def _custom_field_payload(title): + return { + "extension": { + "tags": ["tag1"], + "data_type": "text", + "title": title, + "src": "https://example.com/widget", + "multiple": False, + "config": "{}", + "type": "field", + } + } + + +class TestExtensionCRUD: + def test_create(self, stack, store): + title = h.generate_unique_title("Ext") + resp = stack.extension().create(_custom_field_payload(title)) + h.assert_status(resp, 201) + uid = h.body(resp).get("extension", {}).get("uid") + store["extensions"]["main"] = uid + + def test_find_all(self, stack): + resp = stack.extension().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("extensions", {}).get("main") + if not uid: + pytest.skip("extension was not created") + resp = stack.extension(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store): + uid = store.get("extensions", {}).get("main") + if not uid: + pytest.skip("extension was not created") + resp = stack.extension(uid).update(_custom_field_payload("Updated Ext")) + h.assert_status(resp, 200, 201) + + def test_upload(self, stack): + # Upload an HTML widget extension (multipart upload). + resp = stack.extension().upload({ + "file_name": _EXT_HTML, + "data_type": "text", + "title": h.generate_unique_title("UploadExt"), + "multiple": False, + "tags": {}, + "type": "field", + }) + h.assert_status(resp, 200, 201) + + +class TestExtensionNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.extension("no_such_ext").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_uid_raises(self, stack): + with pytest.raises(Exception): + stack.extension().fetch() + + +class TestExtensionDelete: + def test_delete(self, stack, store): + uid = store.get("extensions", {}).get("main") + if not uid: + pytest.skip("extension was not created") + resp = stack.extension(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_10_webhook.py b/tests/integration/api/test_10_webhook.py new file mode 100644 index 0000000..c3175a9 --- /dev/null +++ b/tests/integration/api/test_10_webhook.py @@ -0,0 +1,98 @@ +"""Webhook API tests — CRUD, executions, export, logs, retry, negatives.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(10) + + +def _webhook_payload(name): + return { + "webhook": { + "name": name, + "destinations": [ + {"target_url": "https://example.com/hook", "http_basic_auth": "", "http_basic_password": "", "custom_header": []} + ], + "channels": ["assets.create"], + "retry_policy": "manual", + "disabled": False, + } + } + + +class TestWebhookCRUD: + def test_create(self, stack, store): + name = h.generate_unique_title("Webhook") + resp = stack.webhooks().create(_webhook_payload(name)) + h.assert_status(resp, 201) + store["webhooks"]["main"] = h.body(resp).get("webhook", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.webhooks().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).fetch() + h.assert_status(resp, 200) + h.validate_webhook_response(resp) + + def test_update(self, stack, store): + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).update(_webhook_payload("Updated Webhook")) + h.assert_status(resp, 200, 201) + + def test_executions(self, stack, store): + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).executions() + h.assert_status(resp, 200) + + def test_export(self, stack, store): + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).export() + h.assert_status(resp, 200) + + +class TestWebhookExecutionOps: + def test_logs_for_unknown_execution(self, stack, store): + # logs() returns 200 with an empty payload for a webhook with no executions. + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).logs("no_such_execution") + h.assert_status(resp, 200, 404) + + def test_retry_unknown_execution(self, stack, store): + uid = store.get("webhooks", {}).get("main") + if not uid: + pytest.skip("webhook not created") + resp = stack.webhooks(uid).retry("no_such_execution") + h.assert_status(resp, 400, 404, 422) + + +class TestWebhookNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.webhooks("no_such_webhook").fetch() + h.assert_status(resp, 404, 422) + + +class TestWebhookDelete: + def test_delete(self, stack, store): + name = h.generate_unique_title("WebhookDel") + created = h.body(stack.webhooks().create(_webhook_payload(name))) + uid = created.get("webhook", {}).get("uid") + if not uid: + pytest.skip("webhook not created") + h.wait(h.SHORT_DELAY) + resp = stack.webhooks(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_11_global_field.py b/tests/integration/api/test_11_global_field.py new file mode 100644 index 0000000..ed09023 --- /dev/null +++ b/tests/integration/api/test_11_global_field.py @@ -0,0 +1,76 @@ +"""Global Field API tests — CRUD, export, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(11) + + +def _global_field_payload(uid, title=None): + return { + "global_field": { + "title": title or f"GF {uid}", + "uid": uid, + "schema": [ + { + "display_name": "Single Line", + "uid": "single_line", + "data_type": "text", + "field_metadata": {"description": "", "default_value": ""}, + "unique": False, + "multiple": False, + "mandatory": False, + } + ], + } + } + + +class TestGlobalFieldCRUD: + def test_create(self, stack, store): + uid = h.generate_valid_uid("gf") + resp = stack.global_fields().create(_global_field_payload(uid)) + h.assert_status(resp, 201) + store["global_fields"]["main"] = uid + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.global_fields().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store["global_fields"]["main"] + resp = stack.global_fields(uid).fetch() + h.assert_status(resp, 200) + h.validate_global_field_response(resp, expected_uid=uid) + + def test_update(self, stack, store): + uid = store["global_fields"]["main"] + payload = _global_field_payload(uid, title="Updated GF") + resp = stack.global_fields(uid).update(payload) + h.assert_status(resp, 200, 201) + + def test_export(self, stack, store): + uid = store["global_fields"]["main"] + resp = stack.global_fields(uid).export() + h.assert_status(resp, 200) + + +class TestGlobalFieldNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.global_fields("no_such_gf").fetch() + h.assert_status(resp, 404, 422) + + +class TestGlobalFieldDelete: + def test_delete(self, stack): + uid = h.generate_valid_uid("gf_del") + stack.global_fields().create(_global_field_payload(uid)) + h.wait(h.SHORT_DELAY) + # Drop Content-Type for the body-less DELETE (the SDK merges + # application/json by default, which the API rejects with 500). The JS + # SDK/axios omits it on body-less deletes. + stack.client.headers.pop("Content-Type", None) + resp = stack.global_fields(uid).delete() + h.assert_status(resp, 200, 204) diff --git a/tests/integration/api/test_12_content_type.py b/tests/integration/api/test_12_content_type.py new file mode 100644 index 0000000..fbf686d --- /dev/null +++ b/tests/integration/api/test_12_content_type.py @@ -0,0 +1,189 @@ +""" +Content Type API tests — full CRUD plus complex-schema round-trip validation. + +Creates the full family of schemas ported from the JS sanity suite (simple, medium, +complex with modular blocks/groups/JSON RTE, author, article with references, +singleton) and verifies the SDK round-trips every field type. Created UIDs are +stashed in `store["content_types"]` for the entry tests (test_14). + +Reference ordering matters: `author` is created before `article`, which references it. +""" + +import pytest + +from data import content_types as ct_data +from framework import helpers as h + +pytestmark = pytest.mark.order(12) + + +class TestContentTypeCRUD: + def test_create_simple(self, stack, store): + resp = stack.content_types().create(ct_data.simple_content_type()) + h.assert_status(resp, 201) + data = h.validate_content_type_response(resp, expected_uid="simple_test") + h.tracked_assert(data["uid"], "created uid").equals("simple_test") + store["content_types"]["simple"] = "simple_test" + + def test_fetch(self, stack, store): + resp = stack.content_types("simple_test").fetch() + h.assert_status(resp, 200) + h.validate_content_type_response(resp, expected_uid="simple_test") + + def test_find_all(self, stack): + resp = stack.content_types().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("content_types"), "content_types list").is_type(list) + + def test_find_with_pagination(self, stack): + query = stack.content_types() + query.add_param("limit", 1) + query.add_param("skip", 0) + resp = query.find() + h.assert_status(resp, 200) + h.tracked_assert(len(h.body(resp).get("content_types", [])) <= 1, "limit respected").equals(True) + + def test_update(self, stack): + current = h.body(stack.content_types("simple_test").fetch())["content_type"] + current["title"] = h.generate_unique_title("Updated Simple") + resp = stack.content_types("simple_test").update({"content_type": current}) + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp)["content_type"]["title"], "updated title").equals(current["title"]) + + def test_references(self, stack): + resp = stack.content_types("simple_test").references() + h.assert_status(resp, 200) + + def test_export(self, stack): + resp = stack.content_types("simple_test").export() + h.assert_status(resp, 200) + + +class TestComplexSchemas: + """Create every field type and confirm the SDK round-trips the full schema.""" + + def test_create_medium(self, stack, store): + resp = stack.content_types().create(ct_data.medium_content_type()) + h.assert_status(resp, 201) + ct = h.validate_content_type_response(resp, expected_uid="medium_complexity") + types = {f["data_type"] for f in ct["schema"]} + # isodate, file, link, number, boolean, text all present + for expected in ("text", "number", "boolean", "isodate", "file", "link"): + h.tracked_assert(expected in types, f"medium has {expected} field").equals(True) + store["content_types"]["medium"] = "medium_complexity" + h.wait(h.SHORT_DELAY) + + def test_create_complex_with_blocks(self, stack, store): + resp = stack.content_types().create(ct_data.complex_content_type()) + h.assert_status(resp, 201) + ct = h.validate_content_type_response(resp, expected_uid="complex_page") + sections = next((f for f in ct["schema"] if f["uid"] == "sections"), None) + h.tracked_assert(sections and sections["data_type"], "sections field type").equals("blocks") + block_uids = {b["uid"] for b in (sections or {}).get("blocks", [])} + for b in ("hero_section", "content_block", "card_grid", "accordion"): + h.tracked_assert(b in block_uids, f"block {b} round-tripped").equals(True) + # group field present + h.tracked_assert(any(f["data_type"] == "group" for f in ct["schema"]), "has group field").equals(True) + store["content_types"]["complex"] = "complex_page" + h.wait(h.SHORT_DELAY) + + def test_create_author(self, stack, store): + resp = stack.content_types().create(ct_data.author_content_type()) + h.assert_status(resp, 201) + h.validate_content_type_response(resp, expected_uid="author") + store["content_types"]["author"] = "author" + h.wait(h.SHORT_DELAY) + + def test_create_article_with_references(self, stack, store): + # 'author' must already exist (created above) for the reference to resolve. + resp = stack.content_types().create(ct_data.article_content_type()) + h.assert_status(resp, 201) + ct = h.validate_content_type_response(resp, expected_uid="article") + refs = [f for f in ct["schema"] if f["data_type"] == "reference"] + h.tracked_assert(len(refs) >= 2, "article has reference fields").equals(True) + store["content_types"]["article"] = "article" + h.wait(h.SHORT_DELAY) + + def test_create_singleton(self, stack, store): + resp = stack.content_types().create(ct_data.singleton_content_type()) + h.assert_status(resp, 201) + ct = h.validate_content_type_response(resp, expected_uid="site_settings") + h.tracked_assert(ct.get("options", {}).get("singleton"), "singleton flag").equals(True) + store["content_types"]["singleton"] = "site_settings" + + +class TestContentTypeSchemaOps: + def test_add_field_to_schema(self, stack): + ct = h.body(stack.content_types("simple_test").fetch())["content_type"] + ct["schema"].append(ct_data.schema_update_add_field()) + resp = stack.content_types("simple_test").update({"content_type": ct}) + h.assert_status(resp, 200) + updated = h.body(resp)["content_type"] + h.tracked_assert( + any(f["uid"] == "new_field" for f in updated["schema"]), "new_field added" + ).equals(True) + + def test_set_field_visibility_rules(self, stack): + # Create a dedicated CT with two extra text fields, then add a field rule + # that shows 'ml' when 'sl' equals a value. + uid = h.generate_valid_uid("ct_fvr") + schema = [ + {"display_name": "Title", "uid": "title", "data_type": "text", + "field_metadata": {"_default": True}, "mandatory": True, "unique": True, "multiple": False}, + {"display_name": "SL", "uid": "sl", "data_type": "text", + "field_metadata": {"_default": True}, "multiple": False}, + {"display_name": "ML", "uid": "ml", "data_type": "text", + "field_metadata": {"_default": True}, "multiple": False}, + ] + stack.content_types().create({"content_type": {"title": "FVR", "uid": uid, "schema": schema}}) + h.wait(h.SHORT_DELAY) + payload = {"content_type": { + "title": "FVR", "uid": uid, "schema": schema, + "field_rules": [{ + "conditions": [{"operand_field": "sl", "operator": "equals", "value": "x"}], + "match_type": "all", + "actions": [{"action": "show", "target_field": "ml"}], + }], + }} + resp = stack.content_types(uid).set_field_visibility_rules(payload) + h.assert_status(resp, 200, 201) + + +class TestContentTypeNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.content_types("does_not_exist_xyz").fetch() + h.assert_status(resp, 404, 422) + h.validate_error_body(resp) + + def test_create_duplicate_uid(self, stack): + resp = stack.content_types().create(ct_data.simple_content_type()) # simple_test already exists + h.assert_status(resp, 409, 422) + h.validate_error_body(resp) + + def test_create_missing_title_field(self, stack): + uid = h.generate_valid_uid("ct_bad") + resp = stack.content_types().create(ct_data.invalid_content_type_missing_title(uid)) + h.assert_status(resp, 400, 422) + h.validate_error_body(resp) + + def test_update_without_uid_raises(self, stack): + with pytest.raises(Exception): + stack.content_types().update({"content_type": {"title": "x"}}) + + +class TestContentTypeDelete: + def test_delete(self, stack): + uid = h.generate_valid_uid("ct_del") + stack.content_types().create(ct_data.simple_content_type(uid=uid, title="Temp Delete")) + h.wait(h.SHORT_DELAY) + resp = stack.content_types(uid).delete() + h.assert_status(resp, 200) + + def test_fetch_after_delete_is_404(self, stack): + uid = h.generate_valid_uid("ct_del2") + stack.content_types().create(ct_data.simple_content_type(uid=uid, title="Temp 404")) + h.wait(h.SHORT_DELAY) + stack.content_types(uid).delete() + h.wait(h.SHORT_DELAY) + resp = stack.content_types(uid).fetch() + h.assert_status(resp, 404, 422) diff --git a/tests/integration/api/test_13_label.py b/tests/integration/api/test_13_label.py new file mode 100644 index 0000000..4581da1 --- /dev/null +++ b/tests/integration/api/test_13_label.py @@ -0,0 +1,51 @@ +"""Label API tests — CRUD, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(13) + + +class TestLabelCRUD: + def test_create(self, stack, store): + name = h.generate_unique_title("Label") + resp = stack.label().create({"label": {"name": name, "content_types": []}}) + h.assert_status(resp, 201) + store["labels"]["main"] = h.body(resp).get("label", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.label().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("labels", {}).get("main") + if not uid: + pytest.skip("label not created") + resp = stack.label(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store): + uid = store.get("labels", {}).get("main") + if not uid: + pytest.skip("label not created") + resp = stack.label(uid).update({"label": {"name": "Updated Label", "content_types": []}}) + h.assert_status(resp, 200, 201) + + +class TestLabelNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.label("no_such_label").fetch() + h.assert_status(resp, 404, 422) + + +class TestLabelDelete: + def test_delete(self, stack): + created = h.body(stack.label().create({"label": {"name": h.generate_unique_title("LabelDel"), "content_types": []}})) + uid = created.get("label", {}).get("uid") + if not uid: + pytest.skip("label not created") + h.wait(h.SHORT_DELAY) + resp = stack.label(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_14_entry.py b/tests/integration/api/test_14_entry.py new file mode 100644 index 0000000..16db542 --- /dev/null +++ b/tests/integration/api/test_14_entry.py @@ -0,0 +1,251 @@ +""" +Entry API tests — CRUD plus complex field round-trip, references, atomic operations, +localization, and version naming. + +Consumes content types created by test_12 (medium/complex/author/article) via `store`, +creating fallbacks if run in isolation. +""" + +import pytest + +from data import content_types as ct_data +from data import entries as entry_data +from framework import helpers as h + +pytestmark = pytest.mark.order(14) + + +def _ensure_ct(stack, store, key, factory, uid): + existing = store.get("content_types", {}).get(key) + if existing: + return existing + stack.content_types().create(factory()) + h.wait(h.SHORT_DELAY) + store.setdefault("content_types", {})[key] = uid + return uid + + +@pytest.fixture(scope="class") +def medium_ct(stack, store): + return _ensure_ct(stack, store, "medium", ct_data.medium_content_type, "medium_complexity") + + +@pytest.fixture(scope="class") +def author_ct(stack, store): + return _ensure_ct(stack, store, "author", ct_data.author_content_type, "author") + + +@pytest.fixture(scope="class") +def article_ct(stack, store): + _ensure_ct(stack, store, "author", ct_data.author_content_type, "author") + return _ensure_ct(stack, store, "article", ct_data.article_content_type, "article") + + +@pytest.fixture(scope="class") +def environment_name(stack, store): + name = store.get("environments", {}).get("main") + if name: + return name + name = h.generate_valid_uid("env_entry") + stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + store.setdefault("environments", {})["main"] = name + return name + + +class TestEntryCRUD: + def test_create(self, stack, store, medium_ct): + resp = stack.content_types(medium_ct).entry().create( + entry_data.medium_entry(h.generate_unique_title("Entry")) + ) + h.assert_status(resp, 201) + data = h.validate_entry_response(resp, medium_ct) + # Round-trip check of populated field types. + h.tracked_assert(data.get("view_count"), "number field round-trip").equals(1250) + h.tracked_assert(data.get("is_featured"), "boolean field round-trip").equals(True) + h.tracked_assert(data.get("categories"), "checkbox multi round-trip").is_type(list) + store["entries"]["main"] = data["uid"] + h.wait(h.SHORT_DELAY) + + def test_fetch(self, stack, store, medium_ct): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).fetch() + h.assert_status(resp, 200) + h.validate_entry_response(resp, medium_ct) + + def test_find_all(self, stack, medium_ct): + resp = stack.content_types(medium_ct).entry().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("entries"), "entries list").is_type(list) + + def test_find_with_pagination(self, stack, medium_ct): + query = stack.content_types(medium_ct).entry() + query.add_param("limit", 1) + resp = query.find() + h.assert_status(resp, 200) + h.tracked_assert(len(h.body(resp).get("entries", [])) <= 1, "limit").equals(True) + + def test_update(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + current = h.body(stack.content_types(medium_ct).entry(entry_uid).fetch())["entry"] + current["summary"] = "updated summary" + resp = stack.content_types(medium_ct).entry(entry_uid).update({"entry": current}) + h.assert_status(resp, 200, 201) + + def test_references(self, stack, store, medium_ct): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).references() + h.assert_status(resp, 200) + + def test_languages(self, stack, store, medium_ct): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).languages() + h.assert_status(resp, 200) + + +class TestComplexEntries: + def test_create_complex_entry(self, stack, store): + ct = _ensure_ct(stack, store, "complex", ct_data.complex_content_type, "complex_page") + resp = stack.content_types(ct).entry().create(entry_data.complex_entry()) + h.assert_status(resp, 201) + data = h.validate_entry_response(resp, ct) + # Modular blocks + group round-trip. + h.tracked_assert(data.get("sections"), "modular blocks round-trip").is_type(list) + h.tracked_assert(data.get("seo", {}).get("meta_title"), "group field round-trip").truthy() + store["entries"]["complex"] = data["uid"] + h.wait(h.SHORT_DELAY) + + def test_create_author_entry(self, stack, store, author_ct): + resp = stack.content_types(author_ct).entry().create(entry_data.author_entry("John Doe")) + h.assert_status(resp, 201) + data = h.validate_entry_response(resp, author_ct) + store["entries"]["author"] = data["uid"] + h.wait(h.SHORT_DELAY) + + def test_create_article_entry(self, stack, store, article_ct): + resp = stack.content_types(article_ct).entry().create(entry_data.article_entry()) + h.assert_status(resp, 201) + data = h.validate_entry_response(resp, article_ct) + store["entries"]["article"] = data["uid"] + h.wait(h.SHORT_DELAY) + + def test_create_article_with_reference(self, stack, store, article_ct): + # Wire the article -> author single reference using the created author entry. + author_uid = store.get("entries", {}).get("author") + if not author_uid: + pytest.skip("author entry not available for reference") + resp = stack.content_types(article_ct).entry().create( + entry_data.article_entry_with_references(author_uid=author_uid) + ) + h.assert_status(resp, 201) + data = h.validate_entry_response(resp, article_ct) + h.tracked_assert(data.get("author"), "reference field round-trip").truthy() + + +class TestEntryAtomicOps: + """Atomic field operations (PUSH/PULL/UPDATE on arrays, ADD/SUB on numbers).""" + + def test_push_tags(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).update(entry_data.atomic_push_entry()) + h.assert_status(resp, 200, 201) + + def test_pull_tags(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).update(entry_data.atomic_pull_entry()) + h.assert_status(resp, 200, 201) + + def test_update_tag(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).update(entry_data.atomic_update_entry()) + h.assert_status(resp, 200, 201) + + def test_add_view_count(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).update(entry_data.atomic_add_subtract()) + h.assert_status(resp, 200, 201) + + def test_subtract_view_count(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).update(entry_data.atomic_subtract()) + h.assert_status(resp, 200, 201) + + +class TestEntryLifecycle: + """export + publish/unpublish.""" + + def test_export(self, stack, store, medium_ct): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).export() + h.assert_status(resp, 200) + + def test_publish(self, stack, store, medium_ct, environment_name): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).publish( + entry_data.publish_config(environment_name) + ) + h.assert_status(resp, 200, 201) + h.wait(h.SHORT_DELAY) + + def test_unpublish(self, stack, store, medium_ct, environment_name): + resp = stack.content_types(medium_ct).entry(store["entries"]["main"]).unpublish( + entry_data.unpublish_config(environment_name) + ) + h.assert_status(resp, 200, 201) + + +class TestEntryVersioning: + def test_version_naming(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + # Name the entry's CURRENT version (it has been updated several times, so + # version 1 may no longer be the latest). Payload must be wrapped in 'entry'. + current = h.body(stack.content_types(medium_ct).entry(entry_uid).fetch())["entry"] + version = current.get("_version", 1) + resp = stack.content_types(medium_ct).entry(entry_uid).version_naming( + version, entry_data.version_name_config() + ) + h.assert_status(resp, 200, 201) + + +class TestEntryLocalization: + def test_localize(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + # Ensure a fr-fr locale exists (created in test_04; create if missing). + stack.locale().create({"locale": {"name": "French", "code": "fr-fr"}}) + h.wait(h.SHORT_DELAY) + resp = stack.content_types(medium_ct).entry(entry_uid).localize( + entry_data.localized_entry_fr_fr(), locale="fr-fr" + ) + h.assert_status(resp, 200, 201) + + def test_unlocalize(self, stack, store, medium_ct): + entry_uid = store["entries"]["main"] + resp = stack.content_types(medium_ct).entry(entry_uid).unlocalize(locale="fr-fr") + h.assert_status(resp, 200, 201) + + +class TestEntryNegative: + def test_fetch_nonexistent(self, stack, medium_ct): + resp = stack.content_types(medium_ct).entry("does_not_exist_uid").fetch() + h.assert_status(resp, 404, 422) + h.validate_error_body(resp) + + def test_create_in_nonexistent_content_type(self, stack): + resp = stack.content_types("no_such_ct_xyz").entry().create( + entry_data.simple_entry(h.generate_unique_title("Orphan")) + ) + h.assert_status(resp, 404, 422) + h.validate_error_body(resp) + + def test_fetch_without_uid_raises(self, stack, medium_ct): + with pytest.raises(Exception): + stack.content_types(medium_ct).entry().fetch() + + +class TestEntryDelete: + def test_delete(self, stack, medium_ct): + created = h.body( + stack.content_types(medium_ct).entry().create( + entry_data.simple_entry(h.generate_unique_title("DelEntry")) + ) + )["entry"] + h.wait(h.SHORT_DELAY) + resp = stack.content_types(medium_ct).entry(created["uid"]).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_15_variant_group.py b/tests/integration/api/test_15_variant_group.py new file mode 100644 index 0000000..168cef5 --- /dev/null +++ b/tests/integration/api/test_15_variant_group.py @@ -0,0 +1,65 @@ +"""Variant Group API tests — CRUD, link/unlink content types, negative cases. + +Variant groups require Personalize. Tests accept 422 and skip downstream work when +the project isn't available. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(15) + + +def _vg_payload(uid, name): + # The variant-group (Personalize) API expects an UNwrapped object — not + # nested under a "variant_group" key. + return {"name": name, "uid": uid, "content_types": []} + + +class TestVariantGroupCRUD: + def test_create(self, stack, store): + uid = h.generate_valid_uid("vg") + resp = stack.variant_group().create(_vg_payload(uid, f"VG {uid}")) + h.assert_status(resp, 200, 201) + if resp.status_code in (200, 201): + store["variant_groups"]["main"] = h.body(resp).get("variant_group", {}).get("uid", uid) + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.variant_group().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("variant_groups", {}).get("main") + if not uid: + pytest.skip("variant group not created (Personalize unavailable)") + resp = stack.variant_group(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store): + uid = store.get("variant_groups", {}).get("main") + if not uid: + pytest.skip("variant group not created") + resp = stack.variant_group(uid).update({"name": "Updated VG", "content_types": []}) + h.assert_status(resp, 200, 201) + + +class TestVariantGroupNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.variant_group("no_such_vg").fetch() + # The variant-group API returns 200 with an empty body for an unknown uid. + h.assert_status(resp, 200) + + +class TestVariantGroupDelete: + def test_delete(self, stack, store): + # Use a throwaway variant group so the shared 'main' one stays available + # for the variants tests (test_16). + uid = h.generate_valid_uid("vg_del") + created = stack.variant_group().create(_vg_payload(uid, f"VGDel {uid}")) + if created.status_code not in (200, 201): + pytest.skip("variant group not created (Personalize unavailable)") + h.wait(h.SHORT_DELAY) + resp = stack.variant_group(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_16_variants.py b/tests/integration/api/test_16_variants.py new file mode 100644 index 0000000..0712317 --- /dev/null +++ b/tests/integration/api/test_16_variants.py @@ -0,0 +1,50 @@ +"""Variants API tests — CRUD within a variant group, negative cases. + +Requires Personalize + an existing variant group; skips gracefully otherwise. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(16) + + +@pytest.fixture(scope="class") +def variant_group_uid(store): + uid = store.get("variant_groups", {}).get("main") + if not uid: + pytest.skip("no variant group available (Personalize unavailable)") + return uid + + +class TestVariants: + def test_create(self, stack, store, variant_group_uid): + uid = h.generate_valid_uid("var") + # Personalize variants expect an unwrapped object (like variant groups). + data = {"name": f"Variant {uid}", "uid": uid} + resp = stack.variant_group(variant_group_uid).variants().create(data) + h.assert_status(resp, 200, 201) + # The API assigns its own server-side uid and returns it UNWRAPPED at the + # top level (not under a "variant" key, and not the uid we sent). + body = h.body(resp) + store["variants"]["main"] = body.get("uid") or body.get("variant", {}).get("uid") + + def test_find(self, stack, variant_group_uid): + resp = stack.variant_group(variant_group_uid).variants().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store, variant_group_uid): + uid = store.get("variants", {}).get("main") + if not uid: + pytest.skip("variant not created") + resp = stack.variant_group(variant_group_uid).variants(uid).fetch() + h.assert_status(resp, 200) + + +class TestVariantsNegative: + def test_fetch_nonexistent(self, stack, variant_group_uid): + resp = stack.variant_group(variant_group_uid).variants("no_such_var").fetch() + # The variants API returns 412 (precondition failed, code 1010) for an + # unknown variant rather than 404. + h.assert_status(resp, 404, 412, 422) diff --git a/tests/integration/api/test_17_entry_variants.py b/tests/integration/api/test_17_entry_variants.py new file mode 100644 index 0000000..5fce336 --- /dev/null +++ b/tests/integration/api/test_17_entry_variants.py @@ -0,0 +1,41 @@ +"""Entry Variants API tests — find/fetch entry variants, negative cases. + +Requires Personalize + an entry. Skips gracefully when prerequisites are missing. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(17) + + +@pytest.fixture(scope="class") +def entry_ctx(store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + if not (entry_uid and ct_uid): + pytest.skip("no entry available for entry variants") + return ct_uid, entry_uid + + +class TestEntryVariants: + def test_find(self, stack, entry_ctx): + ct_uid, entry_uid = entry_ctx + resp = stack.content_types(ct_uid).entry(entry_uid).variants().find() + h.assert_status(resp, 200) + + def test_include_variants_requires_variant_uid(self, stack, entry_ctx): + # EntryVariants.includeVariants() guards on a missing variant_uid and + # raises before issuing the HTTP call. + ct_uid, entry_uid = entry_ctx + with pytest.raises(Exception): + stack.content_types(ct_uid).entry(entry_uid).variants().includeVariants() + + +class TestEntryVariantsNegative: + def test_fetch_nonexistent(self, stack, entry_ctx): + ct_uid, entry_uid = entry_ctx + resp = stack.content_types(ct_uid).entry(entry_uid).variants("no_such_variant").fetch() + # Variants API returns 412 (precondition failed, code 1010) for unknown variant. + h.assert_status(resp, 404, 412, 422) diff --git a/tests/integration/api/test_18_branch.py b/tests/integration/api/test_18_branch.py new file mode 100644 index 0000000..a00bcf5 --- /dev/null +++ b/tests/integration/api/test_18_branch.py @@ -0,0 +1,59 @@ +"""Branch API tests — CRUD, negative cases. + +Branch create/delete are slow on the API; generous waits are used. Stacks cap the +number of branches, so the suite creates at most one and deletes it. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(18) + + +class TestBranchCRUD: + def test_create(self, stack, store): + uid = h.generate_valid_uid("branch") + resp = stack.branch().create({"branch": {"uid": uid, "source": "main"}}) + h.assert_status(resp, 201) + store["branches"]["main"] = uid + h.wait(h.LONG_DELAY) + + def test_find_all(self, stack): + resp = stack.branch().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("branches"), "branches list").is_type(list) + + def test_fetch(self, stack, store): + uid = store.get("branches", {}).get("main") + if not uid: + pytest.skip("branch not created") + resp = stack.branch(uid).fetch() + h.assert_status(resp, 200) + + +class TestBranchNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.branch("no_such_branch").fetch() + h.assert_status(resp, 404, 422) + + +class TestBranchDelete: + def test_delete(self, stack, store): + uid = store.get("branches", {}).get("main") + if not uid: + pytest.skip("branch not created") + # Branch delete requires force=true (the API otherwise returns a 422 + # confirmation prompt). Branch provisioning is async on the API, so a + # freshly created branch can briefly be "not valid" for deletion (422, + # code 905) — retry a few times with a wait until it deletes. + resp = None + for attempt in range(4): + branch = stack.branch(uid) + branch.add_param("force", "true") + resp = branch.delete() + if resp.status_code in (200, 204): + break + h.wait(h.LONG_DELAY) + h.assert_status(resp, 200, 204) + h.wait(h.SHORT_DELAY) diff --git a/tests/integration/api/test_19_alias.py b/tests/integration/api/test_19_alias.py new file mode 100644 index 0000000..fcb784a --- /dev/null +++ b/tests/integration/api/test_19_alias.py @@ -0,0 +1,45 @@ +"""Branch Alias API tests — assign, find, fetch, delete, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(19) + + +class TestAliasCRUD: + def test_assign(self, stack, store): + alias_uid = h.generate_valid_uid("alias") + resp = stack.alias(alias_uid).assign({"branch_alias": {"target_branch": "main"}}) + h.assert_status(resp, 200, 201) + store["aliases"]["main"] = alias_uid + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.alias().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("aliases", {}).get("main") + if not uid: + pytest.skip("alias not created") + resp = stack.alias(uid).fetch() + h.assert_status(resp, 200) + + +class TestAliasNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.alias("no_such_alias").fetch() + h.assert_status(resp, 404, 422) + + +class TestAliasDelete: + def test_delete(self, stack, store): + uid = store.get("aliases", {}).get("main") + if not uid: + pytest.skip("alias not created") + # Branch-alias delete requires force=true (otherwise a 422 confirmation prompt). + alias = stack.alias(uid) + alias.add_param("force", "true") + resp = alias.delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_20_role.py b/tests/integration/api/test_20_role.py new file mode 100644 index 0000000..c1dac0f --- /dev/null +++ b/tests/integration/api/test_20_role.py @@ -0,0 +1,63 @@ +"""Role API tests — CRUD, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(20) + + +def _role_payload(name): + return { + "role": { + "name": name, + "description": "integration test role", + "rules": [{"module": "environment", "environments": [], "acl": {"read": True}}], + } + } + + +class TestRoleCRUD: + def test_create(self, stack, store): + name = h.generate_unique_title("Role") + resp = stack.roles().create(_role_payload(name)) + h.assert_status(resp, 201) + store["roles"]["main"] = h.body(resp).get("role", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.roles().find() + h.assert_status(resp, 200) + h.tracked_assert(h.body(resp).get("roles"), "roles list").is_type(list) + + def test_fetch(self, stack, store): + uid = store.get("roles", {}).get("main") + if not uid: + pytest.skip("role not created") + resp = stack.roles(uid).fetch() + h.assert_status(resp, 200) + h.validate_role_response(resp) + + def test_update(self, stack, store): + uid = store.get("roles", {}).get("main") + if not uid: + pytest.skip("role not created") + resp = stack.roles(uid).update(_role_payload("Updated Role")) + h.assert_status(resp, 200, 201) + + +class TestRoleNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.roles("no_such_role").fetch() + h.assert_status(resp, 404, 422) + + +class TestRoleDelete: + def test_delete(self, stack): + created = h.body(stack.roles().create(_role_payload(h.generate_unique_title("RoleDel")))) + uid = created.get("role", {}).get("uid") + if not uid: + pytest.skip("role not created") + h.wait(h.SHORT_DELAY) + resp = stack.roles(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_21_workflow.py b/tests/integration/api/test_21_workflow.py new file mode 100644 index 0000000..d682275 --- /dev/null +++ b/tests/integration/api/test_21_workflow.py @@ -0,0 +1,210 @@ +""" +Workflow API tests — CRUD, enable/disable, stage transitions, publish rules, +tasks, and negative cases. + +Depends on the `medium` content type from the store (creates one if absent). +Publish-rule operations require an ENABLED workflow, an environment, and a role. +""" + +import pytest + +from data import content_types as ct_data +from framework import helpers as h + +pytestmark = pytest.mark.order(21) + + +@pytest.fixture(scope="class") +def content_type_uid(stack, store): + uid = store.get("content_types", {}).get("medium") + if uid: + return uid + uid = h.generate_valid_uid("ct_wf") + stack.content_types().create(ct_data.medium_content_type(uid)) + h.wait(h.SHORT_DELAY) + store.setdefault("content_types", {})["medium"] = uid + return uid + + +@pytest.fixture(scope="class") +def wf_environment(stack): + """A dedicated environment; returns its uid (publish rules need the env uid).""" + name = h.generate_valid_uid("env_wf") + resp = stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + return h.body(resp).get("environment", {}).get("uid") + + +@pytest.fixture(scope="class") +def wf_entry(stack, content_type_uid): + resp = stack.content_types(content_type_uid).entry().create( + {"entry": {"title": h.generate_unique_title("WFEntry")}} + ) + h.wait(h.SHORT_DELAY) + return h.body(resp).get("entry", {}).get("uid") + + +@pytest.fixture(scope="class") +def wf_stage_uid(stack, store): + uid = store.get("workflows", {}).get("main") + if not uid: + return None + wf = h.body(stack.workflows(uid).fetch()).get("workflow", {}) + stages = wf.get("workflow_stages", []) + return stages[0].get("uid") if stages else None + + +@pytest.fixture(scope="class") +def a_role_uid(stack): + roles = h.body(stack.roles().find()).get("roles", []) + return next((r["uid"] for r in roles), None) + + +def _workflow_payload(name, ct_uid): + return { + "workflow": { + "name": name, + "content_types": [ct_uid], + "branches": ["main"], + "workflow_stages": [ + {"color": "#2196f3", "name": "Draft", "SYS_ACL": {"roles": {"uids": []}, "users": {"uids": ["$all"]}, "others": {}}, "next_available_stages": ["$all"], "entry_lock": "$none"}, + {"color": "#74ba76", "name": "Review", "SYS_ACL": {"roles": {"uids": []}, "users": {"uids": ["$all"]}, "others": {}}, "next_available_stages": ["$all"], "entry_lock": "$none"}, + ], + } + } + + +def _publish_rule_payload(workflow_uid, ct_uid, env_uid, role_uid, stage_uid): + return { + "publishing_rule": { + "workflow": workflow_uid, + "actions": ["publish"], + "branches": ["main"], + "content_types": [ct_uid], + "locales": ["en-us"], + "environment": env_uid, + "approvers": {"users": [], "roles": [role_uid] if role_uid else []}, + "workflow_stage": stage_uid, + "disable_approver_publishing": False, + } + } + + +class TestWorkflowCRUD: + def test_create(self, stack, store, content_type_uid): + resp = stack.workflows().create(_workflow_payload(h.generate_unique_title("Workflow"), content_type_uid)) + h.assert_status(resp, 201) + store["workflows"]["main"] = h.body(resp).get("workflow", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.workflows().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("workflows", {}).get("main") + if not uid: + pytest.skip("workflow not created") + resp = stack.workflows(uid).fetch() + h.assert_status(resp, 200) + h.validate_workflow_response(resp) + + def test_update(self, stack, store, content_type_uid): + uid = store.get("workflows", {}).get("main") + if not uid: + pytest.skip("workflow not created") + resp = stack.workflows(uid).update(_workflow_payload("Updated Workflow", content_type_uid)) + h.assert_status(resp, 200, 201) + + # Workflows are created DISABLED — enable so stage/publish-rule ops can run. + def test_enable(self, stack, store): + uid = store.get("workflows", {}).get("main") + if not uid: + pytest.skip("workflow not created") + resp = stack.workflows(uid).enable() + h.assert_status(resp, 200, 201) + h.wait(h.SHORT_DELAY) + + +class TestWorkflowStagesAndTasks: + def test_fetch_tasks(self, stack): + resp = stack.workflows().fetch_tasks() + h.assert_status(resp, 200) + + def test_set_workflow_stage(self, stack, store, content_type_uid, wf_entry, wf_stage_uid): + if not (wf_entry and wf_stage_uid): + pytest.skip("workflow stage / entry not available") + data = {"workflow": {"workflow_stage": {"comment": "moving", "uid": wf_stage_uid, "notify": False}}} + resp = stack.workflows().set_workflow_stage(content_type_uid, wf_entry, data) + h.assert_status(resp, 200, 201) + + def test_fetch_publish_rules(self, stack): + resp = stack.workflows().fetch_publish_rules() + h.assert_status(resp, 200) + + def test_fetch_publish_rule_content_type(self, stack, content_type_uid): + resp = stack.workflows().fetch_publish_rule_content_type(content_type_uid) + h.assert_status(resp, 200) + + +class TestWorkflowPublishRules: + def test_create_publish_rule(self, stack, store, content_type_uid, wf_environment, a_role_uid, wf_stage_uid): + wf_uid = store.get("workflows", {}).get("main") + if not (wf_uid and wf_stage_uid and wf_environment): + pytest.skip("workflow prerequisites missing") + resp = stack.workflows().create_publish_rule( + _publish_rule_payload(wf_uid, content_type_uid, wf_environment, a_role_uid, wf_stage_uid) + ) + h.assert_status(resp, 200, 201) + store["workflows"]["rule"] = h.body(resp).get("publishing_rule", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_fetch_publish_rule(self, stack, store): + rule_uid = store.get("workflows", {}).get("rule") + if not rule_uid: + pytest.skip("publish rule not created") + resp = stack.workflows().fetch_publish_rule(rule_uid) + h.assert_status(resp, 200) + + def test_update_publish_rule(self, stack, store, content_type_uid, wf_environment, a_role_uid, wf_stage_uid): + rule_uid = store.get("workflows", {}).get("rule") + wf_uid = store.get("workflows", {}).get("main") + if not rule_uid: + pytest.skip("publish rule not created") + resp = stack.workflows(rule_uid).update_publish_rule( + rule_uid, _publish_rule_payload(wf_uid, content_type_uid, wf_environment, a_role_uid, wf_stage_uid) + ) + h.assert_status(resp, 200, 201) + + def test_delete_publish_rule(self, stack, store): + rule_uid = store.get("workflows", {}).get("rule") + if not rule_uid: + pytest.skip("publish rule not created") + resp = stack.workflows().delete_publish_rule(rule_uid) + h.assert_status(resp, 200) + + +class TestWorkflowDisable: + def test_disable(self, stack, store): + uid = store.get("workflows", {}).get("main") + if not uid: + pytest.skip("workflow not created") + resp = stack.workflows(uid).disable() + h.assert_status(resp, 200, 201) + + +class TestWorkflowNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.workflows("no_such_workflow").fetch() + h.assert_status(resp, 404, 422) + + +class TestWorkflowDelete: + def test_delete(self, stack, store): + uid = store.get("workflows", {}).get("main") + if not uid: + pytest.skip("workflow not created") + resp = stack.workflows(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_22_delivery_token.py b/tests/integration/api/test_22_delivery_token.py new file mode 100644 index 0000000..78c6591 --- /dev/null +++ b/tests/integration/api/test_22_delivery_token.py @@ -0,0 +1,80 @@ +"""Delivery Token API tests — CRUD, negative cases. + +Depends on an environment; consumes the one from the store, else creates one. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(22) + + +@pytest.fixture(scope="class") +def environment_name(stack, store): + name = store.get("environments", {}).get("main") + if name: + return name + name = h.generate_valid_uid("env_dt") + stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + store.setdefault("environments", {})["main"] = name + return name + + +def _token_payload(name, env): + # Branches-enabled orgs require a branch (or branch_alias) scope entry. + return { + "token": { + "name": name, + "description": "integration test delivery token", + "scope": [ + {"module": "environment", "environments": [env], "acl": {"read": True}}, + {"module": "branch", "branches": ["main"], "acl": {"read": True}}, + ], + } + } + + +class TestDeliveryTokenCRUD: + def test_create(self, stack, store, environment_name): + name = h.generate_unique_title("DT") + resp = stack.delivery_token().create(_token_payload(name, environment_name)) + h.assert_status(resp, 201) + store["delivery_tokens"]["main"] = h.body(resp).get("token", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.delivery_token().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("delivery_tokens", {}).get("main") + if not uid: + pytest.skip("delivery token not created") + resp = stack.delivery_token(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store, environment_name): + uid = store.get("delivery_tokens", {}).get("main") + if not uid: + pytest.skip("delivery token not created") + resp = stack.delivery_token(uid).update(_token_payload("Updated DT", environment_name)) + h.assert_status(resp, 200, 201) + + +class TestDeliveryTokenNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.delivery_token("no_such_dt").fetch() + h.assert_status(resp, 404, 422) + + +class TestDeliveryTokenDelete: + def test_delete(self, stack, store): + uid = store.get("delivery_tokens", {}).get("main") + if not uid: + pytest.skip("delivery token not created") + resp = stack.delivery_token(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_23_management_token.py b/tests/integration/api/test_23_management_token.py new file mode 100644 index 0000000..20a8496 --- /dev/null +++ b/tests/integration/api/test_23_management_token.py @@ -0,0 +1,67 @@ +"""Management Token API tests — find, fetch, create, update, delete, negative cases. + +The setup already created one management token; this exercises the SDK surface +with its own throwaway token. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(23) + +# Branches-enabled orgs require a branch (or branch_alias) scope entry. +_SCOPE = [ + {"module": "content_type", "acl": {"read": True, "write": True}}, + {"module": "branch", "branches": ["main"], "acl": {"read": True}}, +] + + +def _payload(name): + return {"token": {"name": name, "description": "integration test", "scope": _SCOPE}} + + +class TestManagementTokenCRUD: + def test_create(self, stack, store): + name = h.generate_unique_title("MT") + resp = stack.management_token().create(_payload(name)) + h.assert_status(resp, 201) + store["management_tokens"]["main"] = h.body(resp).get("token", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.management_token().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("management_tokens", {}).get("main") + if not uid: + pytest.skip("management token not created") + resp = stack.management_token(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store): + uid = store.get("management_tokens", {}).get("main") + if not uid: + pytest.skip("management token not created") + resp = stack.management_token(uid).update(_payload("Updated MT")) + h.assert_status(resp, 200, 201) + + +class TestManagementTokenNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.management_token("no_such_mt").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_uid_raises(self, stack): + with pytest.raises(Exception): + stack.management_token().fetch() + + +class TestManagementTokenDelete: + def test_delete(self, stack, store): + uid = store.get("management_tokens", {}).get("main") + if not uid: + pytest.skip("management token not created") + resp = stack.management_token(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_24_release.py b/tests/integration/api/test_24_release.py new file mode 100644 index 0000000..026cdc7 --- /dev/null +++ b/tests/integration/api/test_24_release.py @@ -0,0 +1,63 @@ +"""Release API tests — CRUD, clone, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(24) + + +def _release_payload(name): + return {"release": {"name": name, "description": "integration test release", "locked": False, "archived": False}} + + +class TestReleaseCRUD: + def test_create(self, stack, store): + name = h.generate_unique_title("Release") + resp = stack.releases().create(_release_payload(name)) + h.assert_status(resp, 201) + store["releases"]["main"] = h.body(resp).get("release", {}).get("uid") + h.wait(h.SHORT_DELAY) + + def test_find_all(self, stack): + resp = stack.releases().find() + h.assert_status(resp, 200) + + def test_fetch(self, stack, store): + uid = store.get("releases", {}).get("main") + if not uid: + pytest.skip("release not created") + resp = stack.releases(uid).fetch() + h.assert_status(resp, 200) + h.validate_release_response(resp) + + def test_update(self, stack, store): + uid = store.get("releases", {}).get("main") + if not uid: + pytest.skip("release not created") + resp = stack.releases(uid).update({"release": {"name": "Updated Release", "description": "updated"}}) + h.assert_status(resp, 200, 201) + + def test_clone(self, stack, store): + uid = store.get("releases", {}).get("main") + if not uid: + pytest.skip("release not created") + resp = stack.releases(uid).clone({"release": {"name": h.generate_unique_title("Clone")}}) + h.assert_status(resp, 200, 201) + + +class TestReleaseNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.releases("no_such_release").fetch() + h.assert_status(resp, 404, 422) + + +class TestReleaseDelete: + def test_delete(self, stack): + created = h.body(stack.releases().create(_release_payload(h.generate_unique_title("RelDel")))) + uid = created.get("release", {}).get("uid") + if not uid: + pytest.skip("release not created") + h.wait(h.SHORT_DELAY) + resp = stack.releases(uid).delete() + h.assert_status(resp, 200) diff --git a/tests/integration/api/test_25_release_item.py b/tests/integration/api/test_25_release_item.py new file mode 100644 index 0000000..1043d0b --- /dev/null +++ b/tests/integration/api/test_25_release_item.py @@ -0,0 +1,81 @@ +"""Release Item API tests — find, add/move/delete items, negative cases. + +Creates its own release to operate on. +""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(25) + + +@pytest.fixture(scope="class") +def release_uid(stack, store): + uid = store.get("releases", {}).get("main") + if uid: + return uid + created = h.body(stack.releases().create( + {"release": {"name": h.generate_unique_title("RelItems"), "locked": False, "archived": False}} + )) + uid = created.get("release", {}).get("uid") + if not uid: + pytest.skip("could not create release for item tests") + h.wait(h.SHORT_DELAY) + store.setdefault("releases", {})["main"] = uid + return uid + + +@pytest.fixture(scope="class") +def entry_item(store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + if not (entry_uid and ct_uid): + pytest.skip("no entry available to add to release") + return entry_uid, ct_uid + + +class TestReleaseItems: + def test_find(self, stack, release_uid): + resp = stack.releases(release_uid).item().find() + h.assert_status(resp, 200) + + def test_add_item(self, stack, release_uid, entry_item): + entry_uid, ct_uid = entry_item + data = {"item": {"version": 1, "uid": entry_uid, "content_type_uid": ct_uid, "action": "publish", "locale": "en-us"}} + resp = stack.releases(release_uid).item().create(data) + h.assert_status(resp, 200, 201) + + def test_create_multiple(self, stack, release_uid, entry_item): + entry_uid, ct_uid = entry_item + data = {"items": [{"version": 1, "uid": entry_uid, "content_type_uid": ct_uid, "action": "publish", "locale": "en-us"}]} + resp = stack.releases(release_uid).item().create_multiple(data) + h.assert_status(resp, 200, 201) + + def test_deploy(self, stack, release_uid, entry_item): + # Deploy the populated release to an environment. + env = h.generate_valid_uid("env_rel") + stack.environments().create( + {"environment": {"name": env, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + resp = stack.releases(release_uid).deploy( + {"release": {"environments": [env], "action": "publish", "locale": ["en-us"]}} + ) + h.assert_status(resp, 200, 201) + + def test_delete_items(self, stack, release_uid, entry_item): + # Batch item-removal payload is identifier-sensitive; the call is exercised + # and may return 200 (removed) or 422 (no matching items) depending on the + # release's current item set. + entry_uid, ct_uid = entry_item + data = {"items": [{"uid": entry_uid, "version": 1, "locale": "en-us", "content_type_uid": ct_uid, "action": "publish"}]} + resp = stack.releases(release_uid).item().delete(data) + h.assert_status(resp, 200, 422) + + +class TestReleaseItemsNegative: + def test_find_without_release_raises(self, stack): + # ReleaseItems guards on a missing release_uid before the HTTP call. + with pytest.raises(Exception): + stack.releases().item().find() diff --git a/tests/integration/api/test_26_bulk_operation.py b/tests/integration/api/test_26_bulk_operation.py new file mode 100644 index 0000000..afdc310 --- /dev/null +++ b/tests/integration/api/test_26_bulk_operation.py @@ -0,0 +1,96 @@ +"""Bulk Operation API tests — publish/unpublish, job status, negative cases. + +Best-effort against entries/environment created earlier; accepts a range of +statuses since bulk jobs depend on publishable content + environments. +""" + +import pytest + +from data import content_types as ct_data +from data import entries as entry_data +from framework import helpers as h + +pytestmark = pytest.mark.order(26) + + +def _workflow_payload(name, ct_uid): + return {"workflow": { + "name": name, "content_types": [ct_uid], "branches": ["main"], + "workflow_stages": [ + {"color": "#2196f3", "name": "Draft", "SYS_ACL": {"roles": {"uids": []}, "users": {"uids": ["$all"]}, "others": {}}, "next_available_stages": ["$all"], "entry_lock": "$none"}, + {"color": "#74ba76", "name": "Review", "SYS_ACL": {"roles": {"uids": []}, "users": {"uids": ["$all"]}, "others": {}}, "next_available_stages": ["$all"], "entry_lock": "$none"}, + ], + }} + + +class TestBulkOperation: + def test_publish(self, stack, store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + env = store.get("environments", {}).get("main") + if not (entry_uid and ct_uid and env): + pytest.skip("missing entry/content type/environment for bulk publish") + data = { + "entries": [{"uid": entry_uid, "content_type": ct_uid, "locale": "en-us"}], + "locales": ["en-us"], + "environments": [env], + } + resp = stack.bulk_operation().publish(data) + h.assert_status(resp, 200, 201) + + def test_unpublish(self, stack, store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + env = store.get("environments", {}).get("main") + if not (entry_uid and ct_uid and env): + pytest.skip("missing entry/content type/environment for bulk unpublish") + data = { + "entries": [{"uid": entry_uid, "content_type": ct_uid, "locale": "en-us"}], + "locales": ["en-us"], + "environments": [env], + } + resp = stack.bulk_operation().unpublish(data) + h.assert_status(resp, 200, 201) + + def test_delete(self, stack, store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + if not (entry_uid and ct_uid): + pytest.skip("missing entry/content type for bulk delete") + # Delete a throwaway entry in bulk (don't remove the shared 'main' entry). + throwaway = h.body(stack.content_types(ct_uid).entry().create( + {"entry": {"title": h.generate_unique_title("BulkDel")}} + )).get("entry", {}).get("uid") + h.wait(h.SHORT_DELAY) + data = {"entries": [{"uid": throwaway, "content_type": ct_uid, "locale": "en-us"}]} + resp = stack.bulk_operation().delete(data) + h.assert_status(resp, 200, 201) + + def test_update_workflow(self, stack): + # Bulk workflow-stage update needs a real target stage uid plus the + # 'notify' field. Set up a dedicated content type + entry + enabled + # workflow, then bulk-move the entry to the Review stage. + ct_uid = h.generate_valid_uid("ct_bulkwf") + stack.content_types().create(ct_data.simple_content_type(uid=ct_uid)) + h.wait(h.SHORT_DELAY) + entry_uid = h.body(stack.content_types(ct_uid).entry().create( + entry_data.simple_entry(h.generate_unique_title("BulkWF")))).get("entry", {}).get("uid") + h.wait(h.SHORT_DELAY) + wf = h.body(stack.workflows().create(_workflow_payload(h.generate_unique_title("BulkWF"), ct_uid))) + stage_uid = wf.get("workflow", {}).get("workflow_stages", [{}, {}])[1].get("uid") + stack.workflows(wf.get("workflow", {}).get("uid")).enable() + h.wait(h.SHORT_DELAY) + data = { + "entries": [{"uid": entry_uid, "content_type": ct_uid, "locale": "en-us"}], + "workflow": {"workflow_stage": {"uid": stage_uid, "notify": False, "comment": "bulk move"}}, + } + resp = stack.bulk_operation().update(data) + h.assert_status(resp, 200, 201) + + +class TestBulkOperationNegative: + def test_job_status_invalid(self, stack): + resp = stack.bulk_operation().job_status("no_such_job") + # 401 occurs because bulk job-status is validated against a management + # token before the job id is checked; an invalid/missing one short-circuits. + h.assert_status(resp, 400, 401, 404, 422) diff --git a/tests/integration/api/test_27_publish_queue.py b/tests/integration/api/test_27_publish_queue.py new file mode 100644 index 0000000..4cff9e9 --- /dev/null +++ b/tests/integration/api/test_27_publish_queue.py @@ -0,0 +1,42 @@ +"""Publish Queue API tests — find, fetch, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(27) + + +class TestPublishQueue: + def test_find_all(self, stack): + resp = stack.publish_queue().find() + h.assert_status(resp, 200) + + def test_fetch_first_if_any(self, stack): + items = h.body(stack.publish_queue().find()).get("queue", []) + if not items: + pytest.skip("publish queue is empty") + uid = items[0].get("uid") + resp = stack.publish_queue(uid).fetch() + h.assert_status(resp, 200) + + +class TestPublishQueueCancel: + def test_cancel(self, stack): + # Exercise the cancel (unschedule) endpoint. Our publishes are immediate, + # not scheduled, so cancelling a real/unknown item returns a 4xx — which + # confirms the SDK issues the unschedule request correctly. + items = h.body(stack.publish_queue().find()).get("queue", []) + uid = items[0].get("uid") if items else "no_such_pq" + resp = stack.publish_queue(uid).cancel() + h.assert_status(resp, 200, 400, 404, 422) + + +class TestPublishQueueNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.publish_queue("no_such_pq").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_uid_raises(self, stack): + with pytest.raises(Exception): + stack.publish_queue().fetch() diff --git a/tests/integration/api/test_28_metadata.py b/tests/integration/api/test_28_metadata.py new file mode 100644 index 0000000..86e963a --- /dev/null +++ b/tests/integration/api/test_28_metadata.py @@ -0,0 +1,120 @@ +"""Metadata API tests — find, create against an entry, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(28) + + +def _create_extension(stack): + """Create a field extension; metadata requires a valid extension_uid.""" + resp = stack.extension().create({ + "extension": { + "tags": ["meta"], "data_type": "text", "title": h.generate_unique_title("MetaExt"), + "src": "https://example.com/ext", "multiple": False, "config": "{}", "type": "field", + } + }) + return h.body(resp).get("extension", {}).get("uid") + + +@pytest.fixture(scope="class") +def environment_name(stack, store): + name = store.get("environments", {}).get("main") + if name: + return name + name = h.generate_valid_uid("env_meta") + stack.environments().create( + {"environment": {"name": name, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + store.setdefault("environments", {})["main"] = name + return name + + +def _metadata_payload(entry_uid, ct_uid, extension_uid): + return {"metadata": {"entity_uid": entry_uid, "type": "entry", "extension_uid": extension_uid, + "_content_type_uid": ct_uid, "presets": [{"uid": h.short_id(), "name": "preset", "options": {}}]}} + + +class TestMetadata: + def test_find_all(self, stack): + resp = stack.metadata().find() + h.assert_status(resp, 200) + + def test_create(self, stack, store): + entry_uid = store.get("entries", {}).get("main") + ct_uid = store.get("content_types", {}).get("medium") + if not (entry_uid and ct_uid): + pytest.skip("no entry available for metadata") + extension_uid = _create_extension(stack) + h.tracked_assert(extension_uid, "extension created for metadata").truthy() + h.wait(h.SHORT_DELAY) + data = { + "metadata": { + "entity_uid": entry_uid, + "type": "entry", + "extension_uid": extension_uid, + "_content_type_uid": ct_uid, + "presets": [{"uid": h.short_id(), "name": "preset", "options": {}}], + } + } + resp = stack.metadata().create(data) + h.assert_status(resp, 201) + uid = h.body(resp).get("metadata", {}).get("uid") + h.tracked_assert(uid, "metadata uid").truthy() + store["metadata"] = {"main": uid, "entry": entry_uid, "ct": ct_uid, "ext": extension_uid} + + def test_fetch(self, stack, store): + uid = store.get("metadata", {}).get("main") + if not uid: + pytest.skip("metadata not created") + resp = stack.metadata(uid).fetch() + h.assert_status(resp, 200) + + def test_update(self, stack, store): + meta = store.get("metadata", {}) + if not meta.get("main"): + pytest.skip("metadata not created") + resp = stack.metadata(meta["main"]).update( + _metadata_payload(meta["entry"], meta["ct"], meta["ext"]) + ) + h.assert_status(resp, 200, 201) + + def test_publish(self, stack, store, environment_name): + meta = store.get("metadata", {}) + if not meta.get("main"): + pytest.skip("metadata not created") + resp = stack.metadata(meta["main"]).publish( + {"metadata": {"environments": [environment_name], "locales": ["en-us"]}} + ) + h.assert_status(resp, 200, 201) + h.wait(h.SHORT_DELAY) + + def test_unpublish(self, stack, store, environment_name): + meta = store.get("metadata", {}) + if not meta.get("main"): + pytest.skip("metadata not created") + resp = stack.metadata(meta["main"]).unpublish( + {"metadata": {"environments": [environment_name], "locales": ["en-us"]}} + ) + h.assert_status(resp, 200, 201) + + +class TestMetadataDelete: + def test_delete(self, stack, store): + meta = store.get("metadata", {}) + if not meta.get("main"): + pytest.skip("metadata not created") + resp = stack.metadata(meta["main"]).delete() + h.assert_status(resp, 200) + + +class TestMetadataNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.metadata("no_such_metadata").fetch() + h.assert_status(resp, 404, 422) + + def test_fetch_without_uid_raises(self, stack): + with pytest.raises(Exception): + stack.metadata().fetch() diff --git a/tests/integration/api/test_29_auditlog.py b/tests/integration/api/test_29_auditlog.py new file mode 100644 index 0000000..63d7630 --- /dev/null +++ b/tests/integration/api/test_29_auditlog.py @@ -0,0 +1,27 @@ +"""Audit Log API tests — find, fetch, negative cases.""" + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(29) + + +class TestAuditLog: + def test_find_all(self, stack): + resp = stack.auditlog().find() + h.assert_status(resp, 200) + + def test_fetch_first_if_any(self, stack): + logs = h.body(stack.auditlog().find()).get("logs", []) + if not logs: + pytest.skip("no audit log items") + uid = logs[0].get("uid") + resp = stack.auditlog(uid).fetch() + h.assert_status(resp, 200) + + +class TestAuditLogNegative: + def test_fetch_nonexistent(self, stack): + resp = stack.auditlog("no_such_log").fetch() + h.assert_status(resp, 404, 422) diff --git a/tests/integration/api/test_30_oauth.py b/tests/integration/api/test_30_oauth.py new file mode 100644 index 0000000..e27bbd1 --- /dev/null +++ b/tests/integration/api/test_30_oauth.py @@ -0,0 +1,65 @@ +""" +OAuth API tests — handler construction, authorize-URL generation, token handling. + +Uses the dedicated OAuth app credentials (CLIENT_ID / APP_ID / REDIRECT_URI). The +interactive consent flow can't run headless, so these cover the SDK surface that is +exercisable without a browser: building the authorize URL, PKCE handling, and token +accessors. Skips if OAuth app credentials are not configured. +""" + +import os + +import pytest + +import contentstack_management +from framework import helpers as h + +pytestmark = pytest.mark.order(30) + + +def _oauth_env(): + return ( + os.getenv("APP_ID"), + os.getenv("CLIENT_ID"), + os.getenv("REDIRECT_URI"), + ) + + +@pytest.fixture(scope="class") +def oauth_handler(ctx): + app_id, client_id, redirect_uri = _oauth_env() + if not (app_id and client_id and redirect_uri): + pytest.skip("OAuth app credentials (APP_ID/CLIENT_ID/REDIRECT_URI) not set") + return ctx.client.oauth(app_id=app_id, client_id=client_id, redirect_uri=redirect_uri) + + +class TestOAuth: + def test_handler_created(self, oauth_handler): + h.tracked_assert(oauth_handler, "oauth handler").truthy() + + def test_authorize_url(self, oauth_handler): + url = oauth_handler.authorize() + h.tracked_assert(url, "authorize url").is_type(str) + h.tracked_assert(url, "authorize url scheme").matches(r"^https?://") + + def test_access_token_initially_absent(self, oauth_handler): + # Before any exchange, there should be no access token. NOTE: the SDK's + # get_access_token() references self._access_token which is only set after + # a token exchange, so calling it pre-auth raises AttributeError — an SDK + # bug. We treat "no token" as either a falsy return or that AttributeError. + try: + token = oauth_handler.get_access_token() + h.tracked_assert(token in (None, ""), "no token before exchange").equals(True) + except AttributeError: + pass # SDK bug: _access_token attribute not initialized until set + + +class TestOAuthNegative: + def test_exchange_bad_code(self, oauth_handler): + # A bogus authorization code must not yield a valid token — either the SDK + # raises or returns an error structure. + try: + result = oauth_handler.exchange_code_for_token("invalid_code_xyz") + assert not result or "access_token" not in result + except Exception: + pass # raising on an invalid code is acceptable diff --git a/tests/integration/api/test_31_am_assets.py b/tests/integration/api/test_31_am_assets.py new file mode 100644 index 0000000..6586c85 --- /dev/null +++ b/tests/integration/api/test_31_am_assets.py @@ -0,0 +1,90 @@ +""" +AM 2.0 (DAM 2.0) asset tests — run only against the AM-enabled org (AM_ORG_UID). + +What's AM 2.0-specific vs the normal-org scan tests (test_06): + - asset UIDs are 'am'-prefixed (vs 'blt') + - the `api_version: 3.2` header is required on publish (single/bulk) and is + publish-only — applying it to fetch/upload returns 404 + +Asset scanning itself behaves identically in both orgs (verified): the +include_asset_scan_status=true param surfaces _asset_scan_status with values +pending -> clean | quarantined. The whole file skips when AM_ORG_UID is unset. +""" + +import os + +import pytest + +from framework import helpers as h + +pytestmark = pytest.mark.order(31) + +_ASSET_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets", "sample.png") + + +class TestAMAssetBasics: + def test_upload_has_am_uid_prefix(self, am_stack): + resp = am_stack.assets().upload(_ASSET_PATH) + h.assert_status(resp, 201) + uid = h.body(resp).get("asset", {}).get("uid", "") + h.tracked_assert(uid[:2], "AM 2.0 asset uid prefix").equals("am") + + def test_crud_round_trip(self, am_stack): + uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid") + h.wait(h.SHORT_DELAY) + h.assert_status(am_stack.assets(uid).fetch(), 200) + h.assert_status(am_stack.assets().find(), 200) + h.assert_status(am_stack.assets(uid).version(), 200) + h.assert_status(am_stack.assets(uid).delete(), 200) + + +class TestAMAssetScan: + def test_upload_returns_pending(self, am_stack): + asset = am_stack.assets() + asset.add_param("include_asset_scan_status", "true") + resp = asset.upload(_ASSET_PATH) + h.assert_status(resp, 201) + h.tracked_assert( + h.body(resp).get("asset", {}).get("_asset_scan_status"), "scan status on upload" + ).equals("pending") + + def test_clean_asset_scanned_clean(self, am_stack): + uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid") + status = h.wait_for_scan(am_stack, uid, "clean") + h.tracked_assert(status, "clean file scan result").equals("clean") + + def test_malware_asset_quarantined(self, am_stack, eicar_file): + uid = h.body(am_stack.assets().upload(eicar_file)).get("asset", {}).get("uid") + status = h.wait_for_scan(am_stack, uid, "quarantined") + h.tracked_assert(status, "EICAR scan result").equals("quarantined") + + def test_scan_status_absent_without_param(self, am_stack): + uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid") + asset = h.body(am_stack.assets(uid).fetch()).get("asset", {}) + h.tracked_assert("_asset_scan_status" not in asset, "field absent w/o param").equals(True) + + +class TestAMAssetPublish: + def test_publish_with_api_version_3_2(self, am_stack): + uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid") + # upload() pops Content-Type; restore it before the JSON requests below. + am_stack.client.headers["Content-Type"] = "application/json" + env = h.generate_valid_uid("env_am") + am_stack.environments().create( + {"environment": {"name": env, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}} + ) + h.wait(h.SHORT_DELAY) + am_stack.client.headers["Content-Type"] = "application/json" + asset = am_stack.assets(uid) + asset.add_header("api_version", "3.2") + resp = asset.publish({"asset": {"locales": ["en-us"], "environments": [env]}, "version": 1}) + # Publish always returns success; scan validation happens async on the CDA side. + h.assert_status(resp, 200, 201) + + def test_api_version_3_2_is_publish_only(self, am_stack): + # The api_version: 3.2 header is publish-only — on fetch it 404s. + uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid") + asset = am_stack.assets(uid) + asset.add_header("api_version", "3.2") + resp = asset.fetch() + h.assert_status(resp, 404) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..5ba862c --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,243 @@ +""" +pytest configuration for the CMA Python SDK integration suite. + +Responsibilities: + - load .env + - install the request-capture layer (session-wide) + - run dynamic stack setup once per session; tear it down at the end + - expose `ctx`, `stack`, and `store` fixtures to tests + - collect a per-test record (outcome, captured HTTP calls, tracked assertions) + and render the custom dashboard HTML report at session finish +""" + +import os +import sys +import time +from datetime import datetime + +# Make `framework` / `data` importable as top-level packages, and the repo root +# importable for `contentstack_management`, regardless of pytest's rootdir. +_HERE = os.path.dirname(__file__) +_REPO_ROOT = os.path.abspath(os.path.join(_HERE, "..", "..")) +for _p in (_HERE, _REPO_ROOT): + if _p not in sys.path: + sys.path.insert(0, _p) + +import pytest +from dotenv import load_dotenv + +from framework import capture, report +from framework import setup as setup_mod +from framework.context import reset_store, test_data +from framework.helpers import set_active_tracker, short_id, wait +from framework.report import TestRecord + +load_dotenv() +load_dotenv(os.path.join(_HERE, ".env")) + +# Session-wide accumulators. +_records = [] +_session_started = None +_current_assertions = [] # tracker for the test currently running + +# Reports are written to the repo root with a per-run timestamp so runs are never +# overwritten. Concrete paths are computed at session finish. +_REPORT_ROOT = _REPO_ROOT + + +# --------------------------------------------------------------------------- +# Session-scoped setup / teardown +# --------------------------------------------------------------------------- +@pytest.fixture(scope="session") +def ctx(): + """Dynamically created stack context, shared across the whole run.""" + capture.install() + context = setup_mod.setup() + capture.clear() # discard setup traffic so it doesn't bleed into the first test + yield context + setup_mod.teardown(context) + reset_store() + capture.uninstall() + + +@pytest.fixture(scope="session") +def stack(ctx): + """A ready stack accessor bound to the dynamic stack's api_key.""" + return ctx.client.stack(ctx.stack_api_key) + + +@pytest.fixture(scope="session") +def store(): + """Shared cross-file UID store (content_types, entries, assets, ...).""" + return test_data + + +@pytest.fixture(scope="session") +def am_stack(ctx): + """A stack created in the AM 2.0 (DAM-enabled) org from AM_ORG_UID. + + Skips the whole AM suite when AM_ORG_UID is not configured. Reuses the + authenticated session client; only the stack lives in the AM org. + """ + am_org = os.getenv("AM_ORG_UID") + if not am_org: + pytest.skip("AM_ORG_UID not set — AM 2.0 tests require a DAM-enabled org") + client = ctx.client + # This session fixture runs before the per-test header reset, so restore a + # clean JSON Content-Type (a prior asset upload may have mutated/popped it). + client.client.headers["Content-Type"] = "application/json" + client.client.headers.pop("api_version", None) + client.client.headers["organization_uid"] = am_org # Stack.create org-header workaround + resp = client.stack().create(am_org, {"stack": { + "name": f"SDK_Py_AM_{short_id()}", + "description": "Automated AM 2.0 test stack", + "master_locale": "en-us", + }}) + if resp.status_code not in (200, 201): + client.client.headers["organization_uid"] = ctx.organization_uid + pytest.skip(f"could not create AM stack ({resp.status_code}): {resp.text[:120]}") + api_key = resp.json()["stack"]["api_key"] + wait(5) + yield client.stack(api_key) + # teardown: delete the AM stack, then restore the normal org header + if setup_mod.should_delete_resources(): + try: + client.stack(api_key).delete() + except Exception: # noqa: BLE001 + pass + client.client.headers["organization_uid"] = ctx.organization_uid + + +@pytest.fixture(scope="session") +def eicar_file(tmp_path_factory): + """Path to an EICAR antivirus test file, written at runtime (never committed). + + The asset scanner quarantines this standard test signature, letting us assert + the 'quarantined' scan status. The signature is stored base64-encoded (not as a + raw literal) so the source file itself isn't flagged by antivirus / repo scanners. + """ + import base64 + + signature = base64.b64decode( + "WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=" + ) + path = tmp_path_factory.mktemp("am_scan") / "eicar.com" + path.write_bytes(signature) + return str(path) + + +# --------------------------------------------------------------------------- +# Per-test capture wiring +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _per_test_capture(): + """Clear HTTP capture + assertion tracker before each test.""" + global _current_assertions + _current_assertions = [] + set_active_tracker(_current_assertions) + capture.clear() + yield + set_active_tracker(None) + + +@pytest.fixture(autouse=True) +def _reset_client_headers(request): + """Reset mutable client headers before each test for isolation. + + The SDK shares a single headers dict across all resources on a client, and + several modules mutate it in place — e.g. Assets.upload() pops 'Content-Type' + and Assets.update() sets it to 'multipart/form-data'. Without a reset, a JSON + request that runs after an asset test inherits a broken Content-Type and the + API returns 400/500. We restore a clean JSON baseline and drop leaked + per-call headers (branch) so each test starts from a known state. + """ + context = request.getfixturevalue("ctx") + headers = context.client.client.headers + headers["Content-Type"] = "application/json" + headers.pop("branch", None) + headers.pop("api_version", None) # AM 2.0 publish header leaks otherwise (breaks later calls) + yield + + +def _resource_from_nodeid(nodeid: str) -> str: + """Derive a resource group label from the test file name. + 'tests/integration/api/test_12_content_type.py::...' -> 'content_type' + """ + filename = nodeid.split("::")[0].rsplit("/", 1)[-1] + stem = filename.replace("test_", "").replace(".py", "") + # strip leading order prefix like '12_' + parts = stem.split("_", 1) + if parts[0].isdigit() and len(parts) > 1: + stem = parts[1] + return stem + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Collect a TestRecord on the 'call' phase (and setup-phase skips/errors).""" + outcome = yield + rep = outcome.get_result() + + # Record on call phase; also capture setup-phase skips/failures. + if rep.when == "call" or (rep.when == "setup" and rep.outcome in ("skipped", "failed")): + wasxfail = hasattr(rep, "wasxfail") + if wasxfail and rep.outcome == "passed": + status = "xpassed" # a known-broken op unexpectedly passed (maybe fixed!) + elif wasxfail: + status = "xfailed" # known-broken op failed as expected (tracked, not hidden) + elif rep.outcome == "passed": + status = "passed" + elif rep.outcome == "skipped": + status = "skipped" + else: + status = "failed" + + message = "" + if rep.longrepr is not None and status != "passed": + message = str(rep.longrepr) + + # First line of the test's docstring, if any, as a human description. + description = "" + doc = getattr(getattr(item, "obj", None), "__doc__", None) + if doc: + description = doc.strip().split("\n", 1)[0].strip() + + # Class context (the "describe" block), e.g. TestContentTypeCRUD. + parts = item.nodeid.split("::") + class_name = parts[1] if len(parts) >= 3 else "" + + _records.append( + TestRecord( + nodeid=item.nodeid, + name=item.name, + resource=_resource_from_nodeid(item.nodeid), + outcome=status, + duration_ms=getattr(rep, "duration", 0) * 1000, + message=message, + calls=capture.get_all(), + assertions=list(_current_assertions), + description=description, + group=class_name, + ) + ) + + +# --------------------------------------------------------------------------- +# Report rendering +# --------------------------------------------------------------------------- +def pytest_sessionstart(session): + global _session_started + _session_started = time.time() + + +def pytest_sessionfinish(session, exitstatus): + if not _records: + return + ended = time.time() + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + html_path = os.path.join(_REPORT_ROOT, f"cma-python-report-{ts}.html") + curl_path = os.path.join(_REPORT_ROOT, f"api-requests-{ts}.txt") + report.render(_records, html_path, _session_started or ended, ended) + report.write_curl_log(_records, curl_path) + print(f"\nHTML report: {html_path}") + print(f"cURL log: {curl_path}") diff --git a/tests/integration/data/__init__.py b/tests/integration/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/data/assets/extension.html b/tests/integration/data/assets/extension.html new file mode 100644 index 0000000..b939394 --- /dev/null +++ b/tests/integration/data/assets/extension.html @@ -0,0 +1 @@ +
This is HTML rich text content with " + "bold and italic formatting.
", + "content_json_rte": { + "type": "doc", "uid": "doc_uid", "attrs": {}, "children": [ + {"type": "p", "attrs": {}, "uid": "p_uid_1", + "children": [{"text": "This is JSON RTE content with proper structure."}]}, + {"type": "h2", "attrs": {}, "uid": "h2_uid", + "children": [{"text": "Heading Level 2"}]}, + {"type": "p", "attrs": {}, "uid": "p_uid_2", "children": [ + {"text": "More paragraph content with "}, + {"text": "bold text", "bold": True}, + {"text": " and "}, + {"text": "italic text", "italic": True}, + {"text": "."}]}, + ], + }, + "seo": { + "meta_title": "Complex Page - SEO Title", + "meta_description": "This is the meta description for the complex page entry. " + "It should be between 150-160 characters for optimal SEO.", + "canonical": "https://example.com/complex-page-entry", + }, + "links": [ + {"link": {"title": "Primary Link", "href": "/primary"}, "appearance": "primary", "new_tab": False}, + {"link": {"title": "Secondary Link", "href": "/secondary"}, "appearance": "secondary", "new_tab": True}, + {"link": {"title": "External Link", "href": "https://external.com"}, "appearance": "default", "new_tab": True}, + ], + "sections": [ + {"hero_section": { + "headline": "Welcome to Our Platform", + "subheadline": "Discover amazing features and capabilities that will transform your workflow.", + "cta_link": {"title": "Get Started", "href": "/get-started"}}}, + {"content_block": { + "title": "Our Features", + "content": {"type": "doc", "uid": "feature_doc", "attrs": {}, "children": [ + {"type": "p", "attrs": {}, "uid": "feature_p", + "children": [{"text": "Explore our comprehensive set of features designed for modern teams."}]}]}, + "layout": "two_column"}}, + {"card_grid": { + "grid_title": "Featured Products", "columns": "3", "cards": [ + {"card_title": "Product One", "card_description": "Description for product one with key features.", + "card_link": {"title": "Learn More", "href": "/products/one"}}, + {"card_title": "Product Two", "card_description": "Description for product two with benefits.", + "card_link": {"title": "Learn More", "href": "/products/two"}}, + {"card_title": "Product Three", "card_description": "Description for product three with details.", + "card_link": {"title": "Learn More", "href": "/products/three"}}]}}, + {"accordion": {"items": [ + {"question": "What is this platform?", + "answer": {"type": "doc", "uid": "faq_1", "attrs": {}, "children": [ + {"type": "p", "attrs": {}, "uid": "faq_1_p", + "children": [{"text": "This platform is a comprehensive solution for content management."}]}]}}, + {"question": "How do I get started?", + "answer": {"type": "doc", "uid": "faq_2", "attrs": {}, "children": [ + {"type": "p", "attrs": {}, "uid": "faq_2_p", + "children": [{"text": "Sign up for an account and follow our quick start guide."}]}]}}, + ]}}, + ], + } +} + + +def complex_entry(title: str = None) -> dict: + e = copy.deepcopy(_COMPLEX_ENTRY) + if title: + e["entry"]["title"] = title + return e + + +def author_entry(title: str = "John Doe") -> dict: + return {"entry": { + "title": title, + "url": f"/authors/{title.lower().replace(' ', '-')}", + "email": f"{title.lower().replace(' ', '.')}@example.com", + "job_title": "Senior Developer", + "bio": "Seasoned developer with over 10 years of experience building scalable applications.", + "social_links": [ + {"platform": "twitter", "profile_url": {"title": "@author", "href": "https://twitter.com/author"}}, + {"platform": "linkedin", "profile_url": {"title": title, "href": "https://linkedin.com/in/author"}}, + ], + }} + + +def article_entry(title: str = "Getting Started with the SDK") -> dict: + return {"entry": { + "title": title, + "url": "/articles/getting-started-sdk", + "publish_date": "2024-01-20T00:00:00.000Z", + "excerpt": "Learn how to integrate our SDK into your application with this comprehensive guide.", + "content": {"type": "doc", "uid": "article_content", "attrs": {}, "children": [ + {"type": "h2", "attrs": {}, "uid": "intro_h2", "children": [{"text": "Introduction"}]}, + {"type": "p", "attrs": {}, "uid": "intro_p", + "children": [{"text": "Welcome to our comprehensive SDK guide."}]}, + ]}, + "is_featured": True, + "is_published": True, + "content_tags": ["sdk", "tutorial", "getting-started", "python"], + }} + + +def article_entry_with_references(author_uid: str = None, related_article_uid: str = None, + title: str = "Advanced SDK Patterns") -> dict: + """Article entry that wires up single + multi references when uids are provided.""" + entry = { + "title": title, + "url": "/articles/advanced-sdk-patterns", + "publish_date": "2024-02-15T00:00:00.000Z", + "excerpt": "Deep dive into advanced patterns and best practices for SDK integration.", + "content": {"type": "doc", "uid": "advanced_content", "attrs": {}, "children": [ + {"type": "p", "attrs": {}, "uid": "advanced_p", + "children": [{"text": "This article covers advanced patterns for experienced developers."}]}]}, + "is_featured": False, + "is_published": True, + "content_tags": ["sdk", "advanced", "patterns"], + } + if author_uid: + entry["author"] = [{"uid": author_uid, "_content_type_uid": "author"}] + if related_article_uid: + entry["related_articles"] = [{"uid": related_article_uid, "_content_type_uid": "article"}] + return {"entry": entry} + + +def site_settings_entry() -> dict: + return {"entry": { + "title": "My Test Site", + "footer_text": "© 2024 My Test Site. All rights reserved.\n\nBuilt with Contentstack.", + "analytics_id": "GA-123456789", + }} + + +# ---- Atomic operations ---- +def atomic_push_entry() -> dict: + return {"entry": {"content_tags": {"PUSH": {"data": ["new-tag-1", "new-tag-2"]}}}} + + +def atomic_pull_entry() -> dict: + return {"entry": {"content_tags": {"PULL": {"data": ["tag-to-remove"]}}}} + + +def atomic_update_entry() -> dict: + return {"entry": {"content_tags": {"UPDATE": {"index": 0, "data": "replaced-tag"}}}} + + +def atomic_add_subtract() -> dict: + return {"entry": {"view_count": {"ADD": 100}}} + + +def atomic_subtract() -> dict: + return {"entry": {"view_count": {"SUB": 10}}} + + +def publish_config(environment: str, locale: str = "en-us") -> dict: + return {"entry": {"environments": [environment], "locales": [locale]}} + + +def unpublish_config(environment: str, locale: str = "en-us") -> dict: + return {"entry": {"environments": [environment], "locales": [locale]}} + + +# ---- Localized ---- +def localized_entry_en_us() -> dict: + return {"entry": {"title": "Localized Entry - English", + "description": "This is the English version of the content."}} + + +def localized_entry_fr_fr() -> dict: + return {"entry": {"title": "Entrée localisée - Français", + "description": "Ceci est la version française du contenu."}} + + +# ---- Version naming ---- +def version_name_config(name: str = "Production Release v1", locale: str = "en-us") -> dict: + # locale is required in the body for version naming (per the SDK/API contract). + return {"entry": {"_version_name": name, "locale": locale}} + + +def invalid_entry_missing_title() -> dict: + """Entry missing the title field (note: API saves an _in_progress draft, not a 4xx).""" + return {"entry": {"url": "/no-title"}} diff --git a/tests/integration/framework/__init__.py b/tests/integration/framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/framework/capture.py b/tests/integration/framework/capture.py new file mode 100644 index 0000000..2dfa34b --- /dev/null +++ b/tests/integration/framework/capture.py @@ -0,0 +1,230 @@ +""" +Request/response capture for the integration suite. + +The SDK issues HTTP calls through `requests.request(...)` inside +`contentstack_management._api_client._APIClient._call_request`. We monkeypatch +`requests.request` once per session so every SDK call is recorded with enough detail +to drive the HTML report and a copy-ready cURL command — without changing SDK code. + +This mirrors the JS suite's axios request-capture plugin. +""" + +import json +import os +import time +from typing import Any, Optional + +import requests + +# Ring buffer of captured calls. Cleared per-test by conftest so each test's report +# entry only shows its own traffic. +_captured: list = [] +_MAX_BUFFER = 200 + +# Saved reference to the original requests.request so we can call through + restore. +_original_request = None + +# Headers whose values should be masked in the report / cURL output. +_SENSITIVE_HEADERS = {"authtoken", "authorization"} + +# Cap on response/request body length stored for the report (bytes/chars). +_MAX_BODY = 4000 + + +def _mask(key: str, value: str) -> str: + if key.lower() in _SENSITIVE_HEADERS and isinstance(value, str): + if len(value) > 15: + return value[:10] + "..." + value[-5:] + return "***" + return value + + +def _truncate(text: str) -> str: + if text is None: + return None + if len(text) > _MAX_BODY: + return text[:_MAX_BODY] + "\n... (truncated)" + return text + + +def _stringify(data: Any) -> Optional[str]: + if data is None: + return None + if isinstance(data, (bytes, bytearray)): + return f"No tests recorded.
", + ) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w", encoding="utf-8") as fh: + fh.write(document) + return output_path + + +def _render_resource(resource: str, items: List[TestRecord]) -> str: + p = sum(1 for r in items if r.outcome == "passed") + f = sum(1 for r in items if r.outcome == "failed") + w = sum(1 for r in items if r.has_warning) + s = sum(1 for r in items if r.outcome == "skipped") + + badges = f'{p} passed' + if f: + badges += f'{f} failed' + if w: + badges += f'{w} warning' + if s: + badges += f'{s} skipped' + + # Group by class within resource. + by_group: dict = {} + for r in items: + by_group.setdefault(r.group or "", []).append(r) + + body = [] + for group in by_group: + if group: + body.append(f'{_esc(r.message)}| Check | Expected | Actual |
|---|
{_esc(content)}