TorbatYar/backend/services/experience/app/tests/test_components.py
Mortezakoohjani 203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:43:10 +03:30

263 lines
8.8 KiB
Python

"""Component library API tests — Phase 11.2."""
from __future__ import annotations
import pytest
from app.events.publisher import get_event_publisher
from app.events.types import ExperienceEventType
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
async def _workspace(client, code="ws-comp"):
resp = await client.post(
"/api/v1/workspaces",
json={"code": code, "name": "Workspace", "status": "active"},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _site_page(client, workspace_id: str):
site = await client.post(
"/api/v1/sites",
json={
"workspace_id": workspace_id,
"code": "main",
"name": "Main Site",
},
headers=tenant_headers(TENANT_A),
)
assert site.status_code == 201, site.text
page = await client.post(
"/api/v1/pages",
json={
"workspace_id": workspace_id,
"site_id": site.json()["id"],
"code": "home",
"slug": "home",
"title": "Home",
"page_type": "landing",
},
headers=tenant_headers(TENANT_A),
)
assert page.status_code == 201, page.text
return site.json(), page.json()
@pytest.mark.asyncio
async def test_health_phase_112(client):
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["version"].startswith("0.11.")
caps = await client.get("/capabilities")
body = caps.json()
assert body["features"]["components"] is True
assert body["features"]["component_versions"] is True
assert body["features"]["component_version_immutability"] is True
assert body["features"]["page_component_placements"] is True
assert "hero" in body["component_kinds"]
assert "menu_ref" in body["component_kinds"]
@pytest.mark.asyncio
async def test_component_version_publish_placement_flow(client):
ws = await _workspace(client)
workspace_id = ws["id"]
site, page = await _site_page(client, workspace_id)
component = await client.post(
"/api/v1/components",
json={
"workspace_id": workspace_id,
"code": "hero-main",
"name": "Hero Main",
"kind": "hero",
"category": "marketing",
"tags": ["hero", "landing"],
},
headers=tenant_headers(TENANT_A),
)
assert component.status_code == 201, component.text
component_id = component.json()["id"]
assert component.json()["status"] == "draft"
version = await client.post(
"/api/v1/component-versions",
json={
"workspace_id": workspace_id,
"component_id": component_id,
"definition_schema": {
"type": "object",
"properties": {"title": {"type": "string"}},
},
"props_defaults": {"title": "Welcome"},
"render_hint": {"renderer": "web"},
"changelog": "Initial draft",
},
headers=tenant_headers(TENANT_A),
)
assert version.status_code == 201, version.text
version_id = version.json()["id"]
assert version.json()["version_number"] == 1
assert version.json()["status"] == "draft"
assert version.json()["is_immutable"] is False
listed = await client.get(
f"/api/v1/components?workspace_id={workspace_id}&q=hero&sort_by=name&sort_dir=asc",
headers=tenant_headers(TENANT_A),
)
assert listed.status_code == 200
assert listed.json()["total"] >= 1
assert any(i["id"] == component_id for i in listed.json()["items"])
published = await client.post(
f"/api/v1/component-versions/{version_id}/publish",
headers=tenant_headers(TENANT_A),
)
assert published.status_code == 200, published.text
assert published.json()["status"] == "published"
assert published.json()["is_immutable"] is True
# Immutable: update must fail
blocked = await client.patch(
f"/api/v1/component-versions/{version_id}",
json={"changelog": "nope", "version": published.json()["version"]},
headers=tenant_headers(TENANT_A),
)
assert blocked.status_code == 409
# Draft placement blocked before publish was already done; try unpublished second version
v2 = await client.post(
"/api/v1/component-versions",
json={
"workspace_id": workspace_id,
"component_id": component_id,
"changelog": "v2",
},
headers=tenant_headers(TENANT_A),
)
assert v2.status_code == 201
draft_place = await client.post(
"/api/v1/page-component-placements",
json={
"workspace_id": workspace_id,
"site_id": site["id"],
"page_id": page["id"],
"component_id": component_id,
"component_version_id": v2.json()["id"],
"slot_key": "main.hero",
"sort_order": 0,
},
headers=tenant_headers(TENANT_A),
)
assert draft_place.status_code == 409
placement = await client.post(
"/api/v1/page-component-placements",
json={
"workspace_id": workspace_id,
"site_id": site["id"],
"page_id": page["id"],
"component_id": component_id,
"component_version_id": version_id,
"slot_key": "main.hero",
"sort_order": 10,
"props_override": {"title": "Hello"},
},
headers=tenant_headers(TENANT_A),
)
assert placement.status_code == 201, placement.text
placement_id = placement.json()["id"]
# Retire blocked while in use
retire_blocked = await client.post(
f"/api/v1/component-versions/{version_id}/retire",
headers=tenant_headers(TENANT_A),
)
assert retire_blocked.status_code == 409
# Soft-delete component blocked while placements exist
delete_blocked = await client.post(
f"/api/v1/components/{component_id}/delete",
headers=tenant_headers(TENANT_A),
)
assert delete_blocked.status_code == 409
removed = await client.post(
f"/api/v1/page-component-placements/{placement_id}/delete",
headers=tenant_headers(TENANT_A),
)
assert removed.status_code == 200
assert removed.json()["is_deleted"] is True
retired = await client.post(
f"/api/v1/component-versions/{version_id}/retire",
headers=tenant_headers(TENANT_A),
)
assert retired.status_code == 200
assert retired.json()["status"] == "retired"
archived = await client.post(
f"/api/v1/components/{component_id}/archive",
headers=tenant_headers(TENANT_A),
)
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
events = {e.event_type for e in get_event_publisher().published}
assert ExperienceEventType.COMPONENT_CREATED.value in events
assert ExperienceEventType.COMPONENT_VERSION_CREATED.value in events
assert ExperienceEventType.COMPONENT_VERSION_PUBLISHED.value in events
assert ExperienceEventType.PAGE_COMPONENT_PLACED.value in events
assert ExperienceEventType.PAGE_COMPONENT_REMOVED.value in events
assert ExperienceEventType.COMPONENT_VERSION_RETIRED.value in events
assert ExperienceEventType.COMPONENT_ARCHIVED.value in events
@pytest.mark.asyncio
async def test_component_tenant_isolation(client):
ws = await _workspace(client, code="iso-c")
component = await client.post(
"/api/v1/components",
json={
"workspace_id": ws["id"],
"code": "iso",
"name": "Iso",
"kind": "content",
},
headers=tenant_headers(TENANT_A),
)
assert component.status_code == 201
component_id = component.json()["id"]
other = await client.get(
f"/api/v1/components/{component_id}",
headers=tenant_headers(TENANT_B),
)
assert other.status_code == 404
@pytest.mark.asyncio
async def test_optimistic_lock_on_component(client):
ws = await _workspace(client, code="lock-c")
component = await client.post(
"/api/v1/components",
json={
"workspace_id": ws["id"],
"code": "lock",
"name": "Lock",
"kind": "cta",
},
headers=tenant_headers(TENANT_A),
)
assert component.status_code == 201
body = component.json()
bad = await client.patch(
f"/api/v1/components/{body['id']}",
json={"name": "Nope", "version": body["version"] + 5},
headers=tenant_headers(TENANT_A),
)
assert bad.status_code == 409