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>
375 lines
13 KiB
Python
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
|