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

260 lines
8.6 KiB
Python

"""Localization, RTL/LTR & media-ref API tests — Phase 11.5."""
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-loc"):
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(client, workspace_id: str, code="main"):
resp = await client.post(
"/api/v1/sites",
json={"workspace_id": workspace_id, "code": code, "name": "Main Site"},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _locale_profile(client, workspace_id: str, *, code="fa", language="fa", direction="rtl"):
resp = await client.post(
"/api/v1/locale-profiles",
json={
"workspace_id": workspace_id,
"code": code,
"name": f"Locale {code}",
"language": language,
"text_direction": direction,
"status": "active",
},
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 201, resp.text
return resp.json()
@pytest.mark.asyncio
async def test_health_phase_115(client):
"""Prior phase localization capabilities remain intact after 11.6."""
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"]["site_locale_bindings"] is True
assert body["features"]["localizations"] is True
assert body["features"]["rtl_ltr"] is True
assert body["features"]["media_refs"] is True
assert body["features"]["storage_media_refs_only"] is True
assert body["features"]["forms"] is True
assert body["features"]["surveys"] is True
assert body["features"]["templates"] is True
assert "rtl" in body["text_directions"]
assert "ltr" in body["text_directions"]
assert body["phase"] == "11.10"
assert body["version"].startswith("0.11.10")
@pytest.mark.asyncio
async def test_site_locale_binding_and_localization_lifecycle(client):
ws = await _workspace(client)
workspace_id = ws["id"]
site = await _site(client, workspace_id)
fa = await _locale_profile(client, workspace_id, code="fa", language="fa", direction="rtl")
en = await _locale_profile(
client, workspace_id, code="en", language="en", direction="ltr"
)
binding = await client.post(
"/api/v1/site-locale-bindings",
json={
"workspace_id": workspace_id,
"site_id": site["id"],
"locale_profile_id": fa["id"],
"is_default": True,
},
headers=tenant_headers(TENANT_A),
)
assert binding.status_code == 201, binding.text
binding_body = binding.json()
assert binding_body["locale_code"] == "fa"
assert binding_body["text_direction"] == "rtl"
assert binding_body["is_default"] is True
second = await client.post(
"/api/v1/site-locale-bindings",
json={
"workspace_id": workspace_id,
"site_id": site["id"],
"locale_profile_id": en["id"],
"text_direction": "ltr",
"is_default": True,
"sort_order": 1,
},
headers=tenant_headers(TENANT_A),
)
assert second.status_code == 201, second.text
assert second.json()["is_default"] is True
# Previous default cleared
fa_after = await client.get(
f"/api/v1/site-locale-bindings/{binding_body['id']}",
headers=tenant_headers(TENANT_A),
)
assert fa_after.json()["is_default"] is False
loc = await client.post(
"/api/v1/localizations",
json={
"workspace_id": workspace_id,
"target_type": "site",
"target_id": site["id"],
"locale": "fa",
"text_direction": "rtl",
"title": "سایت اصلی",
"summary": "خلاصه فارسی",
"content_json": {"hero": "سلام"},
"status": "active",
},
headers=tenant_headers(TENANT_A),
)
assert loc.status_code == 201, loc.text
loc_id = loc.json()["id"]
assert loc.json()["text_direction"] == "rtl"
upsert = await client.post(
"/api/v1/localizations",
json={
"workspace_id": workspace_id,
"target_type": "site",
"target_id": site["id"],
"locale": "fa",
"text_direction": "rtl",
"title": "سایت به‌روز",
"status": "active",
"version": loc.json()["version"],
},
headers=tenant_headers(TENANT_A),
)
assert upsert.status_code == 201, upsert.text
assert upsert.json()["id"] == loc_id
assert upsert.json()["title"] == "سایت به‌روز"
assert upsert.json()["version"] == loc.json()["version"] + 1
listed = await client.get(
f"/api/v1/localizations?site_id={site['id']}&locale=fa&q=سایت",
headers=tenant_headers(TENANT_A),
)
# filter uses target_id not site_id — use target filters
listed = await client.get(
f"/api/v1/localizations?target_type=site&target_id={site['id']}&q=سایت",
headers=tenant_headers(TENANT_A),
)
assert listed.status_code == 200
assert listed.json()["total"] >= 1
events = get_event_publisher().published
types = {e.event_type for e in events}
assert ExperienceEventType.SITE_LOCALE_ASSIGNED.value in types
assert ExperienceEventType.LOCALIZATION_UPSERTED.value in types
@pytest.mark.asyncio
async def test_media_ref_storage_only_and_rejects_binary(client):
ws = await _workspace(client, code="ws-media")
workspace_id = ws["id"]
site = await _site(client, workspace_id, code="media-site")
created = await client.post(
"/api/v1/media-refs",
json={
"workspace_id": workspace_id,
"site_id": site["id"],
"kind": "image",
"storage_file_ref": "storage://experience/hero.png",
"alt_text": "Hero",
"locale": "fa",
"text_direction": "rtl",
},
headers=tenant_headers(TENANT_A),
)
assert created.status_code == 201, created.text
body = created.json()
assert body["storage_file_ref"] == "storage://experience/hero.png"
assert "binary" not in body
assert "blob" not in body
rejected = await client.post(
"/api/v1/media-refs",
json={
"workspace_id": workspace_id,
"kind": "image",
"storage_file_ref": "data:image/png;base64,AAAA",
},
headers=tenant_headers(TENANT_A),
)
assert rejected.status_code == 422, rejected.text
rejected_meta = await client.post(
"/api/v1/media-refs",
json={
"workspace_id": workspace_id,
"kind": "image",
"storage_file_ref": "storage://ok.png",
"metadata_json": {"base64": "AAAA"},
},
headers=tenant_headers(TENANT_A),
)
assert rejected_meta.status_code == 422, rejected_meta.text
# Tenant isolation
other = await client.get(
f"/api/v1/media-refs/{body['id']}",
headers=tenant_headers(TENANT_B),
)
assert other.status_code == 404
listed = await client.get(
f"/api/v1/media-refs?workspace_id={workspace_id}&kind=image",
headers=tenant_headers(TENANT_A),
)
assert listed.status_code == 200
assert listed.json()["total"] >= 1
events = get_event_publisher().published
assert any(
e.event_type == ExperienceEventType.MEDIA_REF_CREATED.value for e in events
)
@pytest.mark.asyncio
async def test_media_ref_model_has_no_binary_columns():
from app.models.localization import ExperienceMediaRef
columns = set(ExperienceMediaRef.__table__.columns.keys())
forbidden = {
"binary",
"blob",
"bytes",
"content_bytes",
"file_bytes",
"base64",
"data_uri",
"raw_content",
}
assert forbidden.isdisjoint(columns)
assert "storage_file_ref" in columns