feat(07-01): localized emails, locale-aware templates API, language preference endpoint

- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body
- templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response
- portal.py: PATCH /api/portal/users/me/language endpoint persists language preference
- portal.py: /api/portal/auth/verify response includes user.language field
- portal.py: AuthVerifyResponse adds language field (default 'en')
- test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1)
- test_language_preference.py: 4 integration tests for language preference endpoint
- test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
This commit is contained in:
2026-03-25 16:27:14 -06:00
parent 7a3a4f0fdd
commit 9654982433
6 changed files with 648 additions and 38 deletions

View File

@@ -19,7 +19,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member from shared.api.rbac import PortalCaller, get_portal_caller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.db import get_session from shared.db import get_session
from shared.models.audit import AuditEvent from shared.models.audit import AuditEvent
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
@@ -47,6 +47,7 @@ class AuthVerifyResponse(BaseModel):
role: str role: str
tenant_ids: list[str] tenant_ids: list[str]
active_tenant_id: str | None active_tenant_id: str | None
language: str = "en"
class AuthRegisterRequest(BaseModel): class AuthRegisterRequest(BaseModel):
@@ -302,6 +303,7 @@ async def verify_credentials(
role=user.role, role=user.role,
tenant_ids=tenant_ids, tenant_ids=tenant_ids,
active_tenant_id=active_tenant_id, active_tenant_id=active_tenant_id,
language=user.language,
) )
@@ -842,3 +844,54 @@ async def stop_impersonation(
}, },
) )
await session.commit() await session.commit()
# ---------------------------------------------------------------------------
# Language preference endpoint (Phase 7 multilanguage)
# ---------------------------------------------------------------------------
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
class LanguagePreferenceRequest(BaseModel):
language: str
class LanguagePreferenceResponse(BaseModel):
language: str
@portal_router.patch("/users/me/language", response_model=LanguagePreferenceResponse)
async def update_language_preference(
body: LanguagePreferenceRequest,
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> LanguagePreferenceResponse:
"""
Update the authenticated user's language preference.
Accepts: {"language": "es"} (must be one of: en, es, pt)
Returns: {"language": "es"} on success.
Returns 400 if language is not in the supported set.
Returns 401 if not authenticated (no X-Portal-User-Id header).
"""
if body.language not in _SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported language '{body.language}'. Supported: en, es, pt",
)
result = await session.execute(
select(PortalUser).where(PortalUser.id == caller.user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.language = body.language
await session.commit()
return LanguagePreferenceResponse(language=body.language)

View File

@@ -10,6 +10,11 @@ Endpoints:
GET /api/portal/templates — list active templates (all authenticated users) GET /api/portal/templates — list active templates (all authenticated users)
GET /api/portal/templates/{id} — get template detail (all authenticated users) GET /api/portal/templates/{id} — get template detail (all authenticated users)
POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only) POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only)
Locale-aware responses:
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
English is the base — translations overlay, never replace stored English values in DB.
Unsupported locales silently fall back to English.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,7 +23,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,14 +60,33 @@ class TemplateResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@classmethod @classmethod
def from_orm(cls, tmpl: AgentTemplate) -> "TemplateResponse": def from_orm(cls, tmpl: AgentTemplate, locale: str = "en") -> "TemplateResponse":
"""
Build a TemplateResponse from an ORM AgentTemplate.
When locale != 'en' and the template's translations map contains the
locale key, translated name/description/persona fields are overlaid over
the English defaults. English base fields are never overwritten in the DB.
"""
name = tmpl.name
description = tmpl.description
persona = tmpl.persona
if locale != "en":
translations: dict[str, Any] = tmpl.translations or {}
locale_data: dict[str, Any] = translations.get(locale, {})
if locale_data:
name = locale_data.get("name", name)
description = locale_data.get("description", description)
persona = locale_data.get("persona", persona)
return cls( return cls(
id=str(tmpl.id), id=str(tmpl.id),
name=tmpl.name, name=name,
role=tmpl.role, role=tmpl.role,
description=tmpl.description, description=description,
category=tmpl.category, category=tmpl.category,
persona=tmpl.persona, persona=persona,
system_prompt=tmpl.system_prompt, system_prompt=tmpl.system_prompt,
model_preference=tmpl.model_preference, model_preference=tmpl.model_preference,
tool_assignments=tmpl.tool_assignments, tool_assignments=tmpl.tool_assignments,
@@ -88,6 +112,7 @@ class TemplateDeployResponse(BaseModel):
@templates_router.get("/templates", response_model=list[TemplateResponse]) @templates_router.get("/templates", response_model=list[TemplateResponse])
async def list_templates( async def list_templates(
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller), caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[TemplateResponse]: ) -> list[TemplateResponse]:
@@ -97,6 +122,9 @@ async def list_templates(
Available to all authenticated portal users (any role). Available to all authenticated portal users (any role).
Templates are global — not tenant-scoped, no RLS needed. Templates are global — not tenant-scoped, no RLS needed.
Returns templates ordered by sort_order ascending, then name. Returns templates ordered by sort_order ascending, then name.
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
Unsupported locales fall back to English.
""" """
result = await session.execute( result = await session.execute(
select(AgentTemplate) select(AgentTemplate)
@@ -104,12 +132,13 @@ async def list_templates(
.order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc()) .order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc())
) )
templates = result.scalars().all() templates = result.scalars().all()
return [TemplateResponse.from_orm(t) for t in templates] return [TemplateResponse.from_orm(t, locale=locale) for t in templates]
@templates_router.get("/templates/{template_id}", response_model=TemplateResponse) @templates_router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template( async def get_template(
template_id: uuid.UUID, template_id: uuid.UUID,
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller), caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> TemplateResponse: ) -> TemplateResponse:
@@ -118,6 +147,8 @@ async def get_template(
Returns 404 if the template does not exist or is inactive. Returns 404 if the template does not exist or is inactive.
Available to all authenticated portal users (any role). Available to all authenticated portal users (any role).
Pass ?locale=es or ?locale=pt to receive translated fields.
""" """
result = await session.execute( result = await session.execute(
select(AgentTemplate).where( select(AgentTemplate).where(
@@ -128,7 +159,7 @@ async def get_template(
tmpl = result.scalar_one_or_none() tmpl = result.scalar_one_or_none()
if tmpl is None: if tmpl is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return TemplateResponse.from_orm(tmpl) return TemplateResponse.from_orm(tmpl, locale=locale)
@templates_router.post( @templates_router.post(

View File

@@ -7,6 +7,9 @@ Phase 1 architectural constraint). Uses stdlib smtplib — no additional depende
If SMTP is not configured (empty smtp_host), logs a warning and returns without If SMTP is not configured (empty smtp_host), logs a warning and returns without
sending. This allows the invitation flow to function in dev environments without sending. This allows the invitation flow to function in dev environments without
a mail server. a mail server.
Supports localized invitation emails in English (en), Spanish (es), and Portuguese (pt).
Falls back to English for unsupported locales.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,35 +23,21 @@ from shared.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
def send_invite_email( # ---------------------------------------------------------------------------
to_email: str, # Localized email copy
invitee_name: str, # ---------------------------------------------------------------------------
tenant_name: str,
invite_url: str,
) -> None:
"""
Send an invitation email via SMTP.
Args: _SUBJECTS: dict[str, str] = {
to_email: Recipient email address. "en": "You've been invited to join {tenant_name} on Konstruct",
invitee_name: Recipient's display name (for personalization). "es": "Has sido invitado a unirte a {tenant_name} en Konstruct",
tenant_name: Name of the tenant they're being invited to. "pt": "Voce foi convidado para se juntar a {tenant_name} no Konstruct",
invite_url: The full invitation acceptance URL (includes raw token). }
Note: _TEXT_BODIES: dict[str, str] = {
Called from a Celery task (sync). Silently skips if smtp_host is empty. "en": """\
""" Hi {invitee_name},
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
subject = f"You've been invited to join {tenant_name} on Konstruct"
text_body = f"""Hi {invitee_name},
You've been invited to join {tenant_name} on Konstruct, the AI workforce platform. You've been invited to join {tenant_name} on Konstruct, the AI workforce platform.
@@ -61,9 +50,42 @@ This invitation expires in 48 hours.
If you did not expect this invitation, you can safely ignore this email. If you did not expect this invitation, you can safely ignore this email.
— The Konstruct Team — The Konstruct Team
""" """,
"es": """\
Hola {invitee_name},
html_body = f"""<html> Has sido invitado a unirte a {tenant_name} en Konstruct, la plataforma de empleados de IA.
Haz clic en el enlace a continuacion para aceptar tu invitacion y configurar tu cuenta:
{invite_url}
Esta invitacion vence en 48 horas.
Si no esperabas esta invitacion, puedes ignorar este correo de forma segura.
— El Equipo de Konstruct
""",
"pt": """\
Ola {invitee_name},
Voce foi convidado para se juntar a {tenant_name} no Konstruct, a plataforma de funcionarios de IA.
Clique no link abaixo para aceitar o seu convite e configurar sua conta:
{invite_url}
Este convite expira em 48 horas.
Se voce nao estava esperando este convite, pode ignorar este e-mail com seguranca.
— O Time Konstruct
""",
}
_HTML_BODIES: dict[str, str] = {
"en": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;"> <body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to join {tenant_name}</h2> <h2>You've been invited to join {tenant_name}</h2>
<p>Hi {invitee_name},</p> <p>Hi {invitee_name},</p>
@@ -83,7 +105,96 @@ If you did not expect this invitation, you can safely ignore this email.
you can safely ignore it. you can safely ignore it.
</p> </p>
</body> </body>
</html>""" </html>""",
"es": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Has sido invitado a unirte a {tenant_name}</h2>
<p>Hola {invitee_name},</p>
<p>
Has sido invitado a unirte a <strong>{tenant_name}</strong> en
<strong>Konstruct</strong>, la plataforma de empleados de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceptar Invitacion
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Esta invitacion vence en 48 horas. Si no esperabas este correo,
puedes ignorarlo de forma segura.
</p>
</body>
</html>""",
"pt": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Voce foi convidado para se juntar a {tenant_name}</h2>
<p>Ola {invitee_name},</p>
<p>
Voce foi convidado para se juntar a <strong>{tenant_name}</strong> no
<strong>Konstruct</strong>, a plataforma de funcionarios de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceitar Convite
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Este convite expira em 48 horas. Se voce nao estava esperando este e-mail,
pode ignora-lo com seguranca.
</p>
</body>
</html>""",
}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
language: str = "en",
) -> None:
"""
Send an invitation email via SMTP, optionally in the inviter's language.
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
language: Language for the email body. Supported: 'en', 'es', 'pt'.
Falls back to 'en' for unsupported locales.
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
# Normalize language — fall back to English for unsupported locales
lang = language if language in _SUPPORTED_LANGUAGES else "en"
subject = _SUBJECTS[lang].format(tenant_name=tenant_name)
text_body = _TEXT_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
html_body = _HTML_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
@@ -101,7 +212,9 @@ If you did not expect this invitation, you can safely ignore this email.
if settings.smtp_username and settings.smtp_password: if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password) server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, [to_email], msg.as_string()) server.sendmail(settings.smtp_from_email, [to_email], msg.as_string())
logger.info("Invite email sent to %s for tenant %s", to_email, tenant_name) logger.info(
"Invite email sent to %s for tenant %s (language=%s)", to_email, tenant_name, lang
)
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to send invite email to %s (smtp_host=%s)", "Failed to send invite email to %s (smtp_host=%s)",

View File

@@ -0,0 +1,186 @@
"""
Integration tests for the language preference PATCH endpoint.
Tests:
- PATCH /api/portal/users/me/language with valid language returns 200
- PATCH with unsupported language returns 400
- PATCH to "pt" then GET /api/portal/auth/verify includes language="pt"
- PATCH without auth returns 401
Uses the same pattern as existing integration tests:
- Session override via app.dependency_overrides
- X-Portal-User-Id / X-Portal-User-Role header injection for auth
- db_session fixture from tests/conftest.py (Alembic migrations applied)
"""
from __future__ import annotations
import uuid
import bcrypt
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.portal import portal_router
from shared.db import get_session
from shared.models.auth import PortalUser
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def make_app(session: AsyncSession) -> FastAPI:
"""Build a minimal FastAPI test app with portal router."""
app = FastAPI()
app.include_router(portal_router)
async def override_get_session(): # type: ignore[return]
yield session
app.dependency_overrides[get_session] = override_get_session
return app
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def auth_headers(user_id: uuid.UUID, role: str = "customer_admin") -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": role,
}
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
async def _create_user(
session: AsyncSession,
role: str = "customer_admin",
language: str = "en",
) -> PortalUser:
suffix = uuid.uuid4().hex[:8]
hashed = bcrypt.hashpw(b"testpassword123", bcrypt.gensalt()).decode()
user = PortalUser(
id=uuid.uuid4(),
email=f"langtest-{suffix}@example.com",
hashed_password=hashed,
name=f"Language Test User {suffix}",
role=role,
language=language,
)
session.add(user)
await session.flush()
return user
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def lang_client(db_session: AsyncSession) -> AsyncClient:
"""HTTP client with portal router mounted."""
app = make_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def lang_user(db_session: AsyncSession) -> PortalUser:
"""Create a customer_admin user with default language 'en'."""
user = await _create_user(db_session, role="customer_admin", language="en")
await db_session.commit()
return user
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_language_valid(
lang_client: AsyncClient,
lang_user: PortalUser,
) -> None:
"""PATCH /api/portal/users/me/language with valid language returns 200 and new language."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "es"},
headers=auth_headers(lang_user.id),
)
assert response.status_code == 200
data = response.json()
assert data["language"] == "es"
@pytest.mark.asyncio
async def test_patch_language_invalid(
lang_client: AsyncClient,
lang_user: PortalUser,
) -> None:
"""PATCH with unsupported language 'fr' returns 400."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "fr"},
headers=auth_headers(lang_user.id),
)
assert response.status_code == 400
assert "fr" in response.json()["detail"].lower() or "unsupported" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_patch_language_persists(
lang_client: AsyncClient,
lang_user: PortalUser,
db_session: AsyncSession,
) -> None:
"""PATCH to 'pt', then GET /api/portal/auth/verify includes language='pt'."""
# First PATCH to "pt"
patch_response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "pt"},
headers=auth_headers(lang_user.id),
)
assert patch_response.status_code == 200
assert patch_response.json()["language"] == "pt"
# Verify via /auth/verify — need to pass email+password
# Re-fetch user to get credentials, then call auth/verify
verify_response = await lang_client.post(
"/api/portal/auth/verify",
json={"email": lang_user.email, "password": "testpassword123"},
)
assert verify_response.status_code == 200
verify_data = verify_response.json()
assert verify_data["language"] == "pt", (
f"Expected language='pt' in auth/verify response, got: {verify_data.get('language')!r}"
)
@pytest.mark.asyncio
async def test_patch_language_unauthenticated(
lang_client: AsyncClient,
) -> None:
"""PATCH without auth headers returns 401 or 422 (missing required headers)."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "es"},
)
# FastAPI raises 422 when required headers are missing entirely (before auth guard runs).
# Both 401 and 422 are acceptable rejections of unauthenticated requests.
assert response.status_code in (401, 422)

View File

@@ -0,0 +1,226 @@
"""
Integration tests for locale-aware template API endpoints.
Tests:
- GET /api/portal/templates (no locale) returns English fields
- GET /api/portal/templates?locale=es returns Spanish-translated fields
- GET /api/portal/templates?locale=pt returns Portuguese-translated fields
- GET /api/portal/templates?locale=fr falls back to English
- Translated fields overlay English base, English values still in DB
Uses the same pattern as existing integration tests:
- Session override via app.dependency_overrides
- X-Portal-User-Id / X-Portal-User-Role header injection
- db_session fixture from tests/conftest.py (Alembic migrations applied)
"""
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.portal import portal_router
from shared.api.templates import templates_router
from shared.db import get_session
from shared.models.tenant import AgentTemplate
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def make_app(session: AsyncSession) -> FastAPI:
"""Build a minimal FastAPI test app with portal + templates routers."""
app = FastAPI()
app.include_router(portal_router)
app.include_router(templates_router)
async def override_get_session(): # type: ignore[return]
yield session
app.dependency_overrides[get_session] = override_get_session
return app
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def platform_admin_headers(user_id: uuid.UUID) -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": "platform_admin",
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def i18n_client(db_session: AsyncSession) -> AsyncClient:
"""HTTP client with portal + templates router mounted."""
app = make_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
def admin_user_id() -> uuid.UUID:
"""Fixed UUID for a fake platform_admin — no DB row needed for header-based auth."""
return uuid.UUID("00000000-0000-0000-0000-000000000099")
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_templates_default_locale(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates (no locale param) returns English fields."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# All returned names should be English (Customer Support Rep is the first by sort_order)
names = {t["name"] for t in templates}
assert "Customer Support Rep" in names, f"Expected English template name, got: {names}"
@pytest.mark.asyncio
async def test_list_templates_spanish(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=es returns Spanish-translated name/description/persona."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=es", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Find the Customer Support Rep template (ID: 000...001)
support_tmpl = next(
(t for t in templates if "soporte" in t["name"].lower() or "support" in t["name"].lower()),
None,
)
assert support_tmpl is not None, f"Customer support template not found in: {[t['name'] for t in templates]}"
# Spanish name should differ from English
assert support_tmpl["name"] != "Customer Support Rep", (
f"Expected Spanish translation, got English name: {support_tmpl['name']!r}"
)
# Spanish description should be present and non-empty
assert len(support_tmpl["description"]) > 10
@pytest.mark.asyncio
async def test_list_templates_portuguese(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=pt returns Portuguese-translated fields."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=pt", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Find the Customer Support Rep template
support_tmpl = next(
(t for t in templates if "suporte" in t["name"].lower() or "support" in t["name"].lower()),
None,
)
assert support_tmpl is not None, f"Customer support template not found in: {[t['name'] for t in templates]}"
# Portuguese name should differ from English
assert support_tmpl["name"] != "Customer Support Rep", (
f"Expected Portuguese translation, got English name: {support_tmpl['name']!r}"
)
# Portuguese description should be present and non-empty
assert len(support_tmpl["description"]) > 10
@pytest.mark.asyncio
async def test_list_templates_unsupported_locale(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=fr falls back to English."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=fr", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Names should be English (fallback)
names = {t["name"] for t in templates}
assert "Customer Support Rep" in names, (
f"Expected English fallback for unsupported locale 'fr', got names: {names}"
)
@pytest.mark.asyncio
async def test_template_translations_overlay(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
db_session: AsyncSession,
) -> None:
"""Translated fields overlay English, English base fields still in DB."""
headers = platform_admin_headers(admin_user_id)
# Get Spanish-translated templates
es_response = await i18n_client.get("/api/portal/templates?locale=es", headers=headers)
assert es_response.status_code == 200
es_templates = es_response.json()
# Get English templates (default)
en_response = await i18n_client.get("/api/portal/templates", headers=headers)
assert en_response.status_code == 200
en_templates = en_response.json()
# Find the support template in both
es_support = next((t for t in es_templates if "soporte" in t["name"].lower()), None)
en_support = next((t for t in en_templates if t["name"] == "Customer Support Rep"), None)
assert es_support is not None, "Spanish support template not found"
assert en_support is not None, "English support template not found"
# They should share the same template ID
assert es_support["id"] == en_support["id"], "Template IDs should match across locales"
# Names should differ between locales
assert es_support["name"] != en_support["name"], (
"Spanish and English names should differ for Customer Support Rep template"
)
# English base values must still be present in DB (not overwritten)
result = await db_session.execute(
select(AgentTemplate).where(
AgentTemplate.id == uuid.UUID(en_support["id"])
)
)
tmpl_orm = result.scalar_one_or_none()
assert tmpl_orm is not None
assert tmpl_orm.name == "Customer Support Rep", (
f"DB English name should be unchanged, got: {tmpl_orm.name!r}"
)

View File

@@ -29,6 +29,7 @@ def _make_user(role: str, email: str = "test@example.com") -> PortalUser:
user.email = email user.email = email
user.name = "Test User" user.name = "Test User"
user.role = role user.role = role
user.language = "en"
# Real bcrypt hash for password "testpassword" # Real bcrypt hash for password "testpassword"
user.hashed_password = bcrypt.hashpw(b"testpassword", bcrypt.gensalt()).decode() user.hashed_password = bcrypt.hashpw(b"testpassword", bcrypt.gensalt()).decode()
user.created_at = datetime.now(tz=timezone.utc) user.created_at = datetime.now(tz=timezone.utc)