Skip to content

Commit 4bb93fb

Browse files
authored
Merge pull request #168 from contentstack/test/cma-python-integration-rewrite
test(integration): dynamic-stack CMA SDK sanity suite + AM 2.0 coverage + asset Content-Type fix (v1.10.1)
2 parents 8975c51 + 8a597b0 commit 4bb93fb

52 files changed

Lines changed: 5083 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,17 @@ tests/config/default.yml
133133
.vscode/settings.json
134134
run.py
135135
tests/resources/.DS_Store
136-
.talismanrc
137136
tests/.DS_Store
138-
tests/resources/.DS_Store
139137
.DS_Store
140138
*/data/regions.json
141-
.talismanrc
139+
# Local backup of legacy tests (do not commit)
140+
141+
# --- CMA integration suite: do not track ---
142+
docs/
143+
tests_backup_legacy/
144+
tests/integration/report/
145+
tests/integration/.env
146+
tests/integration/.env.example
147+
# Timestamped reports written at repo root
148+
cma-python-report-*.html
149+
api-requests-*.txt

.talismanrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,7 @@ fileignoreconfig:
406406
- filename: tests/mock/assets/test_assets_mock.py
407407
checksum: 12c9091bb88c0c12712f046b29fb4a9dce3b95ecc45f4ea46bbc3fd4529742a0
408408
version: "1.0"
409+
fileignoreconfig:
410+
- filename: tests/integration/framework/capture.py
411+
checksum: c9680c4ee3e1def0765fd89767fa1383aee5d195222fa021a19618607373047c
412+
version: "1.0"

AGENTS.md

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
| Language | Python ≥ 3.9 (`setup.py` `python_requires`) |
1717
| Build | `setuptools` / `setup.py`; package `contentstack_management` |
1818
| HTTP | `requests`, `requests-toolbelt`, `urllib3` |
19-
| Tests | `pytest``tests/unit`, `tests/api`, `tests/mock` |
19+
| Tests | `pytest``tests/integration` (live e2e / sanity, dynamic stack), `tests/unit`, `tests/mock`, `tests/api` (legacy, superseded by `tests/integration`) |
2020
| Lint / coverage | `pylint`, `coverage` (see `requirements.txt`) |
2121
| Secrets / hooks | Talisman, Snyk (see `README.md` development setup) |
2222

@@ -29,24 +29,42 @@
2929
| `contentstack_management/stack/stack.py` | **Stack**-scoped CMA operations |
3030
| `contentstack_management/*/` | Domain modules (entries, assets, webhooks, taxonomies, …) |
3131
| `contentstack_management/__init__.py` | Public exports |
32-
| `tests/cred.py` | **`get_credentials()`****dotenv** + env vars for API/mock tests |
32+
| `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`. |
33+
| `tests/cred.py` | **`get_credentials()`****dotenv** + env vars for the legacy `tests/api` / `tests/mock` suites |
3334

3435
## Commands (quick reference)
3536

3637
| Command Type | Command |
3738
|---|---|
3839
| Install | `pip install -e ".[dev]"` |
40+
| **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. |
41+
| Sanity, keep stack | `DELETE_DYNAMIC_RESOURCES=false pytest tests/integration` (preserve the created stack for debugging) |
42+
| Sanity, one resource | `pytest tests/integration/api/test_12_content_type.py` |
3943
| Test (unit) | `pytest tests/unit/ -v` |
40-
| Test (API, live) | `pytest tests/api/ -v` (needs `.env` — see `tests/cred.py`) |
4144
| Test (mock) | `pytest tests/mock/ -v` |
42-
| Coverage | `coverage run -m pytest tests/unit/` |
45+
| Test (legacy API, live) | `pytest tests/api/ -v` (needs `.env` — see `tests/cred.py`) |
46+
| Coverage (CI) | `coverage run -m pytest tests/unit/` |
4347
| Lint | `pylint contentstack_management/` |
4448

45-
## Environment variables (API / integration tests)
49+
> **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.
4650
47-
Loaded via **`tests/cred.py`** (`load_dotenv()`). Examples include **`HOST`**, **`APIKEY`**, **`AUTHTOKEN`**, **`MANAGEMENT_TOKEN`**, **`ORG_UID`**, and resource UIDs (**`CONTENT_TYPE_UID`**, **`ENTRY_UID`**, …). See that file for the full list.
51+
## Environment variables
4852

49-
Do not commit secrets.
53+
**Sanity / e2e suite** (`tests/integration`) — configured via **`tests/integration/.env`** (gitignored). No pre-existing stack/UIDs needed; the suite creates everything at runtime.
54+
55+
| Var | Required | Purpose |
56+
|-----|----------|---------|
57+
| `EMAIL`, `PASSWORD` || Login for the run (a **non-2FA** account) |
58+
| `HOST` || API host (e.g. `api.contentstack.io`) |
59+
| `ORGANIZATION` || Org the dynamic test stack is created in |
60+
| `MFA_SECRET` || TOTP secret (for the OAuth/2FA account, not the primary login) |
61+
| `DELETE_DYNAMIC_RESOURCES` || `false` keeps the created stack for debugging (default deletes) |
62+
| `CLIENT_ID`, `APP_ID`, `REDIRECT_URI` || OAuth tests |
63+
| `PERSONALIZE_HOST` || Personalize project for variant tests |
64+
65+
**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.
66+
67+
Do not commit secrets. `tests/integration/.env`, `docs/`, and the repo-root `cma-python-report-*.html` / `api-requests-*.txt` are gitignored.
5068

5169
## Where the documentation lives: skills
5270

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# CHANGELOG
22

33
## Content Management SDK For Python
4+
---
5+
## v1.10.1
6+
7+
#### Date: 26 June 2026
8+
9+
- 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.
10+
- 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.
11+
412
---
513
## v1.10.0
614

contentstack_management/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def get_contentstack_endpoint(region='us', service='', omit_https=False):
102102
__author__ = 'dev-ex'
103103
__status__ = 'debug'
104104
__region__ = 'na'
105-
__version__ = '1.10.0'
105+
__version__ = '1.10.1'
106106
__host__ = 'api.contentstack.io'
107107
__protocol__ = 'https://'
108108
__api_version__ = 'v3'

contentstack_management/assets/assets.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,17 @@ def replace(self, file_path):
174174
"""
175175

176176
url = f"assets/{self.asset_uid}"
177-
Parameter.add_header(self, "Content-Type", "multipart/form-data")
178-
files = {"asset": open(f"{file_path}",'rb')}
179-
return self.client.put(url, headers = self.client.headers, params = self.params, files = files)
177+
# _api_client._call_request does headers.update(self.headers) so a per-request
178+
# copy is overwritten before the request is sent. The only way to suppress
179+
# Content-Type is to temporarily remove it from the shared dict so requests can
180+
# set multipart/form-data with the correct boundary automatically.
181+
saved_ct = self.client.headers.pop("Content-Type", None)
182+
try:
183+
with open(file_path, 'rb') as fh:
184+
return self.client.put(url, headers=self.client.headers, params=self.params, files={"asset": fh})
185+
finally:
186+
if saved_ct is not None:
187+
self.client.headers["Content-Type"] = saved_ct
180188

181189
def generate(self, data):
182190
"""
@@ -407,8 +415,11 @@ def update(self, data):
407415
if self.asset_uid is None or '':
408416
raise Exception(ASSET_UID_REQUIRED)
409417
url = f"assets/{self.asset_uid}"
410-
Parameter.add_header(self, "Content-Type", "multipart/form-data")
411-
return self.client.put(url, headers = self.client.headers, params = self.params, data = data)
418+
# Use a per-request header copy with Content-Type explicitly set to application/json
419+
# so this call is not affected by whatever a preceding upload/replace left in
420+
# the shared headers dict.
421+
request_headers = {**self.client.headers, "Content-Type": "application/json"}
422+
return self.client.put(url, headers=request_headers, params=self.params, data=data)
412423

413424
def publish(self, data):
414425
"""

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ pylint>=2.0.0
77
requests-toolbelt>=1.0.0,<2.0.0
88
pyotp==2.9.0
99
packaging>=24.0
10+
# Integration test suite
11+
pytest>=7.0
12+
pytest-order>=1.2.0
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""User API tests — profile fetch and update."""
2+
3+
import pytest
4+
5+
from framework import helpers as h
6+
7+
pytestmark = pytest.mark.order(1)
8+
9+
10+
class TestUser:
11+
def test_fetch(self, ctx):
12+
resp = ctx.client.user().fetch()
13+
h.assert_status(resp, 200)
14+
user = h.body(resp).get("user", {})
15+
h.tracked_assert(user.get("uid"), "user uid").truthy()
16+
17+
def test_update_noop(self, ctx):
18+
# Send the current first_name back — a harmless update that exercises PUT /user.
19+
current = h.body(ctx.client.user().fetch()).get("user", {})
20+
payload = {"user": {"first_name": current.get("first_name", "Test")}}
21+
resp = ctx.client.user().update(payload)
22+
h.assert_status(resp, 200, 201)
23+
24+
25+
class TestUserAuthOps:
26+
"""Account auth endpoints exercised safely (bogus tokens / non-real email)."""
27+
28+
def test_activate_bogus_token(self, ctx):
29+
resp = ctx.client.user().activate("bogus_activation_token", {"user": {"password": "Test@12345"}})
30+
h.assert_status(resp, 400, 404, 422)
31+
32+
def test_reset_password_bogus_token(self, ctx):
33+
resp = ctx.client.user().reset_password(
34+
{"user": {"reset_password_token": "bogus", "password": "Test@12345", "password_confirmation": "Test@12345"}}
35+
)
36+
h.assert_status(resp, 400, 404, 422)
37+
38+
def test_forgot_password(self, ctx):
39+
# Triggers a reset email to a non-real address; APIs typically return 200
40+
# regardless (to avoid email enumeration) or a 422.
41+
resp = ctx.client.user().forgot_password({"user": {"email": "noreply+test@example.com"}})
42+
h.assert_status(resp, 200, 201, 422, 429)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Organization API tests — fetch, roles, stacks, logs, negative cases."""
2+
3+
import pytest
4+
5+
from framework import helpers as h
6+
7+
pytestmark = pytest.mark.order(2)
8+
9+
10+
class TestOrganization:
11+
def test_find_all(self, ctx):
12+
resp = ctx.client.organizations().find()
13+
h.assert_status(resp, 200)
14+
h.tracked_assert(h.body(resp).get("organizations"), "orgs list").is_type(list)
15+
16+
def test_fetch(self, ctx):
17+
resp = ctx.client.organizations(ctx.organization_uid).fetch()
18+
h.assert_status(resp, 200)
19+
org = h.body(resp).get("organization", {})
20+
h.tracked_assert(org.get("uid"), "org uid").equals(ctx.organization_uid)
21+
22+
def test_roles(self, ctx):
23+
resp = ctx.client.organizations(ctx.organization_uid).roles()
24+
h.assert_status(resp, 200)
25+
26+
def test_stacks(self, ctx):
27+
resp = ctx.client.organizations(ctx.organization_uid).stacks()
28+
h.assert_status(resp, 200)
29+
30+
def test_logs(self, ctx):
31+
resp = ctx.client.organizations(ctx.organization_uid).logs()
32+
h.assert_status(resp, 200)
33+
34+
35+
class TestOrganizationOwnership:
36+
"""Exercised safely with invalid data so no real invite/transfer occurs."""
37+
38+
def test_add_users_invalid(self, ctx):
39+
resp = ctx.client.organizations(ctx.organization_uid).add_users({"share": {}})
40+
h.assert_status(resp, 400, 403, 422)
41+
42+
def test_transfer_ownership_invalid(self, ctx):
43+
resp = ctx.client.organizations(ctx.organization_uid).transfer_ownership({"transfer_to": "not-an-email"})
44+
h.assert_status(resp, 400, 403, 422)
45+
46+
47+
class TestOrganizationNegative:
48+
def test_fetch_nonexistent(self, ctx):
49+
resp = ctx.client.organizations("org_does_not_exist").fetch()
50+
h.assert_status(resp, 404, 422, 403)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Stack API tests — fetch, settings, users, share/unshare."""
2+
3+
import os
4+
5+
import pytest
6+
7+
from framework import helpers as h
8+
9+
pytestmark = pytest.mark.order(3)
10+
11+
12+
class TestStack:
13+
def test_fetch(self, ctx):
14+
resp = ctx.client.stack(ctx.stack_api_key).fetch()
15+
h.assert_status(resp, 200)
16+
stack = h.body(resp).get("stack", {})
17+
h.tracked_assert(stack.get("api_key"), "api_key").equals(ctx.stack_api_key)
18+
19+
def test_settings(self, ctx):
20+
resp = ctx.client.stack(ctx.stack_api_key).settings()
21+
h.assert_status(resp, 200)
22+
23+
def test_create_settings(self, ctx):
24+
data = {
25+
"stack_settings": {
26+
"stack_variables": {"enforce_unique_urls": "true"},
27+
}
28+
}
29+
resp = ctx.client.stack(ctx.stack_api_key).create_settings(data)
30+
h.assert_status(resp, 200, 201)
31+
32+
def test_users(self, ctx):
33+
resp = ctx.client.stack(ctx.stack_api_key).users()
34+
h.assert_status(resp, 200)
35+
36+
def test_update(self, ctx):
37+
data = {"stack": {"description": "updated by integration suite"}}
38+
resp = ctx.client.stack(ctx.stack_api_key).update(data)
39+
h.assert_status(resp, 200, 201)
40+
41+
def test_reset_settings(self, ctx):
42+
resp = ctx.client.stack(ctx.stack_api_key).reset_settings({"stack_settings": {}})
43+
h.assert_status(resp, 200, 201)
44+
45+
46+
class TestStackOwnership:
47+
"""Ownership/role operations exercised safely (no real transfer occurs)."""
48+
49+
def test_update_user_role(self, ctx):
50+
# Map the current user to a role; on a fresh single-user stack this may
51+
# be accepted (200) or rejected (422) — both confirm the SDK call works.
52+
roles = h.body(ctx.client.stack(ctx.stack_api_key).roles().find()).get("roles", [])
53+
role_uid = next((r["uid"] for r in roles), None)
54+
if not (role_uid and ctx.user_uid):
55+
pytest.skip("no role/user available")
56+
resp = ctx.client.stack(ctx.stack_api_key).update_user_role({"users": {ctx.user_uid: [role_uid]}})
57+
# 404 when the user isn't a separately-added stack member (owner self-assign).
58+
h.assert_status(resp, 200, 201, 404, 422)
59+
60+
def test_transfer_ownership_invalid(self, ctx):
61+
# Transferring to an invalid address must fail — exercises the endpoint
62+
# without actually handing the stack to anyone.
63+
resp = ctx.client.stack(ctx.stack_api_key).transfer_ownership({"transfer_to": "not-an-email"})
64+
h.assert_status(resp, 400, 422)
65+
66+
def test_accept_ownership_bogus_token(self, ctx):
67+
# Accepting with a bogus token must fail.
68+
resp = ctx.client.stack(ctx.stack_api_key).accept_ownership(ctx.user_uid or "uid", "bogus_token")
69+
h.assert_status(resp, 400, 404, 422)
70+
71+
72+
class TestStackSharing:
73+
def test_share(self, ctx):
74+
member = os.getenv("MEMBER_EMAIL")
75+
if not member:
76+
pytest.skip("MEMBER_EMAIL not set")
77+
# Sharing requires a valid role mapping per email — an empty roles object
78+
# is rejected with 422 "roles is not valid".
79+
roles = h.body(ctx.client.stack(ctx.stack_api_key).roles().find()).get("roles", [])
80+
role_uid = next((r["uid"] for r in roles if r.get("name") != "Admin"),
81+
roles[0]["uid"] if roles else None)
82+
if not role_uid:
83+
pytest.skip("no role available to share with")
84+
data = {"emails": [member], "roles": {member: [role_uid]}}
85+
resp = ctx.client.stack(ctx.stack_api_key).share(data)
86+
h.assert_status(resp, 200, 201)
87+
88+
def test_unshare(self, ctx):
89+
member = os.getenv("MEMBER_EMAIL")
90+
if not member:
91+
pytest.skip("MEMBER_EMAIL not set")
92+
resp = ctx.client.stack(ctx.stack_api_key).unshare({"email": member})
93+
h.assert_status(resp, 200, 201)

0 commit comments

Comments
 (0)