TorbatYar/backend/services/experience/app/tests/test_themes.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

375 lines
13 KiB
Python

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