"""Theme & layout API tests — Phase 11.3 (versioned themes/layouts).""" 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-theme"): 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, code="main"): site = await client.post( "/api/v1/sites", json={"workspace_id": workspace_id, "code": code, "name": "Main"}, 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_113(client): resp = await client.get("/health") assert resp.status_code == 200 # Health version tracks current service version (forward-compatible). assert resp.json()["version"].startswith("0.11.") caps = await client.get("/capabilities") body = caps.json() # Phase 11.3 (themes/layouts) capabilities remain true regardless of the # currently active phase (forward-compatible with later phases). assert body["features"]["themes"] is True assert body["features"]["theme_versions"] is True assert body["features"]["layouts"] is True assert body["features"]["layout_versions"] is True assert body["features"]["directionality_ready_shells"] is True assert "both" in body["direction_modes"] @pytest.mark.asyncio async def test_theme_layout_versioned_flow(client): ws = await _workspace(client) workspace_id = ws["id"] site, page = await _site_page(client, workspace_id) # --- Theme catalog entry (draft) --- theme = await client.post( "/api/v1/themes", json={ "workspace_id": workspace_id, "code": "brand-a", "name": "Brand A", "direction_mode": "rtl", }, headers=tenant_headers(TENANT_A), ) assert theme.status_code == 201, theme.text theme_id = theme.json()["id"] assert theme.json()["status"] == "draft" # --- Theme version (draft) with tokens --- version = await client.post( "/api/v1/theme-versions", json={ "workspace_id": workspace_id, "theme_id": theme_id, "tokens": { "--color-primary": "#0d6efd", "--font-body": "Vazirmatn", }, "brand_shell": {"logo_slot": "header.logo"}, "direction_mode": "rtl", }, headers=tenant_headers(TENANT_A), ) assert version.status_code == 201, version.text version_id = version.json()["id"] assert version.json()["status"] == "draft" assert version.json()["version_number"] == 1 # Cannot bind a site to a draft (unpublished) theme/version blocked = await client.post( "/api/v1/site-theme-bindings", json={ "workspace_id": workspace_id, "site_id": site["id"], "theme_id": theme_id, "theme_version_id": version_id, }, headers=tenant_headers(TENANT_A), ) assert blocked.status_code == 409 # --- Publish theme version — theme auto-activates from draft --- published = await client.post( f"/api/v1/theme-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 theme_after_publish = await client.get( f"/api/v1/themes/{theme_id}", headers=tenant_headers(TENANT_A) ) assert theme_after_publish.json()["status"] == "active" assert theme_after_publish.json()["published_version_id"] == version_id # Idempotent explicit activation (already active) activated = await client.post( f"/api/v1/themes/{theme_id}/activate", headers=tenant_headers(TENANT_A), ) assert activated.status_code == 200 assert activated.json()["status"] == "active" # --- Bind theme version to site --- binding = await client.post( "/api/v1/site-theme-bindings", json={ "workspace_id": workspace_id, "site_id": site["id"], "theme_id": theme_id, "theme_version_id": version_id, }, headers=tenant_headers(TENANT_A), ) assert binding.status_code == 201, binding.text binding_id = binding.json()["id"] assert binding.json()["is_active"] is True # --- Layout catalog entry + version --- layout = await client.post( "/api/v1/layouts", json={ "workspace_id": workspace_id, "code": "landing-shell", "name": "Landing Shell", "direction_mode": "both", "kind": "page_shell", }, headers=tenant_headers(TENANT_A), ) assert layout.status_code == 201, layout.text layout_id = layout.json()["id"] layout_version = await client.post( "/api/v1/layout-versions", json={ "workspace_id": workspace_id, "layout_id": layout_id, "regions": [ {"key": "header", "role": "header"}, {"key": "main", "role": "main"}, {"key": "footer", "role": "footer"}, ], "structure_shell": {"grid": "single-column"}, }, headers=tenant_headers(TENANT_A), ) assert layout_version.status_code == 201, layout_version.text layout_version_id = layout_version.json()["id"] layout_version_pub = await client.post( f"/api/v1/layout-versions/{layout_version_id}/publish", headers=tenant_headers(TENANT_A), ) assert layout_version_pub.status_code == 200, layout_version_pub.text listed = await client.get( f"/api/v1/layouts?workspace_id={workspace_id}&q=landing&sort_by=name", headers=tenant_headers(TENANT_A), ) assert listed.status_code == 200 assert listed.json()["total"] >= 1 # --- Bind layout version to page --- page_binding = await client.post( "/api/v1/page-layout-bindings", json={ "workspace_id": workspace_id, "site_id": site["id"], "page_id": page["id"], "layout_id": layout_id, "layout_version_id": layout_version_id, }, headers=tenant_headers(TENANT_A), ) assert page_binding.status_code == 201, page_binding.text page_binding_id = page_binding.json()["id"] # Soft-delete blocked while theme/version is actively bound del_theme = await client.post( f"/api/v1/themes/{theme_id}/delete", headers=tenant_headers(TENANT_A), ) assert del_theme.status_code == 409 retire_blocked = await client.post( f"/api/v1/theme-versions/{version_id}/retire", headers=tenant_headers(TENANT_A), ) assert retire_blocked.status_code == 409 # --- Unassign bindings --- unassigned_theme = await client.post( f"/api/v1/site-theme-bindings/{binding_id}/unassign", headers=tenant_headers(TENANT_A), ) assert unassigned_theme.status_code == 200 assert unassigned_theme.json()["is_deleted"] is True assert unassigned_theme.json()["is_active"] is False unassigned_layout = await client.post( f"/api/v1/page-layout-bindings/{page_binding_id}/unassign", headers=tenant_headers(TENANT_A), ) assert unassigned_layout.status_code == 200 assert unassigned_layout.json()["is_active"] is False archived = await client.post( f"/api/v1/themes/{theme_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.THEME_CREATED.value in events assert ExperienceEventType.THEME_VERSION_CREATED.value in events assert ExperienceEventType.THEME_VERSION_PUBLISHED.value in events assert ExperienceEventType.THEME_ACTIVATED.value in events assert ExperienceEventType.SITE_THEME_ASSIGNED.value in events assert ExperienceEventType.SITE_THEME_UNASSIGNED.value in events assert ExperienceEventType.LAYOUT_CREATED.value in events assert ExperienceEventType.LAYOUT_VERSION_CREATED.value in events assert ExperienceEventType.LAYOUT_VERSION_PUBLISHED.value in events assert ExperienceEventType.PAGE_LAYOUT_ASSIGNED.value in events assert ExperienceEventType.PAGE_LAYOUT_UNASSIGNED.value in events assert ExperienceEventType.THEME_ARCHIVED.value in events @pytest.mark.asyncio async def test_site_theme_binding_replaces_previous(client): ws = await _workspace(client, code="ws-replace") workspace_id = ws["id"] site, _page = await _site_page(client, workspace_id, code="replace-site") theme = await client.post( "/api/v1/themes", json={"workspace_id": workspace_id, "code": "t1", "name": "T1"}, headers=tenant_headers(TENANT_A), ) theme_id = theme.json()["id"] v1 = await client.post( "/api/v1/theme-versions", json={ "workspace_id": workspace_id, "theme_id": theme_id, "tokens": {"--color": "#111"}, }, headers=tenant_headers(TENANT_A), ) v1_id = v1.json()["id"] await client.post( f"/api/v1/theme-versions/{v1_id}/publish", headers=tenant_headers(TENANT_A) ) b1 = await client.post( "/api/v1/site-theme-bindings", json={ "workspace_id": workspace_id, "site_id": site["id"], "theme_id": theme_id, "theme_version_id": v1_id, }, headers=tenant_headers(TENANT_A), ) assert b1.status_code == 201 v2 = await client.post( "/api/v1/theme-versions", json={ "workspace_id": workspace_id, "theme_id": theme_id, "tokens": {"--color": "#222"}, }, headers=tenant_headers(TENANT_A), ) v2_id = v2.json()["id"] await client.post( f"/api/v1/theme-versions/{v2_id}/publish", headers=tenant_headers(TENANT_A) ) b2 = await client.post( "/api/v1/site-theme-bindings", json={ "workspace_id": workspace_id, "site_id": site["id"], "theme_id": theme_id, "theme_version_id": v2_id, }, headers=tenant_headers(TENANT_A), ) assert b2.status_code == 201 events = {e.event_type for e in get_event_publisher().published} assert ExperienceEventType.SITE_THEME_REPLACED.value in events @pytest.mark.asyncio async def test_theme_tenant_isolation(client): ws = await _workspace(client, code="iso-t") theme = await client.post( "/api/v1/themes", json={ "workspace_id": ws["id"], "code": "iso", "name": "Iso", "direction_mode": "ltr", }, headers=tenant_headers(TENANT_A), ) assert theme.status_code == 201 other = await client.get( f"/api/v1/themes/{theme.json()['id']}", headers=tenant_headers(TENANT_B), ) assert other.status_code == 404 @pytest.mark.asyncio async def test_theme_optimistic_lock(client): ws = await _workspace(client, code="lock-t") theme = await client.post( "/api/v1/themes", json={ "workspace_id": ws["id"], "code": "lock", "name": "Lock", }, headers=tenant_headers(TENANT_A), ) body = theme.json() bad = await client.patch( f"/api/v1/themes/{body['id']}", json={"name": "Nope", "version": body["version"] + 9}, headers=tenant_headers(TENANT_A), ) assert bad.status_code == 409