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>
263 lines
8.8 KiB
Python
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
|