"""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