From 96549824330abeaec4efc5d427c4842fc409a57e Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Wed, 25 Mar 2026 16:27:14 -0600 Subject: [PATCH] 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) --- packages/shared/shared/api/portal.py | 55 ++++- packages/shared/shared/api/templates.py | 45 +++- packages/shared/shared/email.py | 173 +++++++++++--- tests/integration/test_language_preference.py | 186 ++++++++++++++ tests/integration/test_templates_i18n.py | 226 ++++++++++++++++++ tests/unit/test_portal_auth.py | 1 + 6 files changed, 648 insertions(+), 38 deletions(-) create mode 100644 tests/integration/test_language_preference.py create mode 100644 tests/integration/test_templates_i18n.py diff --git a/packages/shared/shared/api/portal.py b/packages/shared/shared/api/portal.py index bc3b8d4..5ecb4a5 100644 --- a/packages/shared/shared/api/portal.py +++ b/packages/shared/shared/api/portal.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import select, text 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.models.audit import AuditEvent from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole @@ -47,6 +47,7 @@ class AuthVerifyResponse(BaseModel): role: str tenant_ids: list[str] active_tenant_id: str | None + language: str = "en" class AuthRegisterRequest(BaseModel): @@ -302,6 +303,7 @@ async def verify_credentials( role=user.role, tenant_ids=tenant_ids, active_tenant_id=active_tenant_id, + language=user.language, ) @@ -842,3 +844,54 @@ async def stop_impersonation( }, ) 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) diff --git a/packages/shared/shared/api/templates.py b/packages/shared/shared/api/templates.py index 41c0e08..9736249 100644 --- a/packages/shared/shared/api/templates.py +++ b/packages/shared/shared/api/templates.py @@ -10,6 +10,11 @@ Endpoints: GET /api/portal/templates — list active templates (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) + +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 @@ -18,7 +23,7 @@ import uuid from datetime import datetime from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -55,14 +60,33 @@ class TemplateResponse(BaseModel): model_config = {"from_attributes": True} @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( id=str(tmpl.id), - name=tmpl.name, + name=name, role=tmpl.role, - description=tmpl.description, + description=description, category=tmpl.category, - persona=tmpl.persona, + persona=persona, system_prompt=tmpl.system_prompt, model_preference=tmpl.model_preference, tool_assignments=tmpl.tool_assignments, @@ -88,6 +112,7 @@ class TemplateDeployResponse(BaseModel): @templates_router.get("/templates", response_model=list[TemplateResponse]) async def list_templates( + locale: str = Query(default="en", description="Response locale: en | es | pt"), caller: PortalCaller = Depends(get_portal_caller), session: AsyncSession = Depends(get_session), ) -> list[TemplateResponse]: @@ -97,6 +122,9 @@ async def list_templates( Available to all authenticated portal users (any role). Templates are global — not tenant-scoped, no RLS needed. 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( select(AgentTemplate) @@ -104,12 +132,13 @@ async def list_templates( .order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc()) ) 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) async def get_template( template_id: uuid.UUID, + locale: str = Query(default="en", description="Response locale: en | es | pt"), caller: PortalCaller = Depends(get_portal_caller), session: AsyncSession = Depends(get_session), ) -> TemplateResponse: @@ -118,6 +147,8 @@ async def get_template( Returns 404 if the template does not exist or is inactive. Available to all authenticated portal users (any role). + + Pass ?locale=es or ?locale=pt to receive translated fields. """ result = await session.execute( select(AgentTemplate).where( @@ -128,7 +159,7 @@ async def get_template( tmpl = result.scalar_one_or_none() if tmpl is None: 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( diff --git a/packages/shared/shared/email.py b/packages/shared/shared/email.py index b0cbc6f..ec56c93 100644 --- a/packages/shared/shared/email.py +++ b/packages/shared/shared/email.py @@ -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 sending. This allows the invitation flow to function in dev environments without 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 @@ -20,35 +23,21 @@ from shared.config import settings logger = logging.getLogger(__name__) +_SUPPORTED_LANGUAGES = {"en", "es", "pt"} -def send_invite_email( - to_email: str, - invitee_name: str, - tenant_name: str, - invite_url: str, -) -> None: - """ - Send an invitation email via SMTP. +# --------------------------------------------------------------------------- +# Localized email copy +# --------------------------------------------------------------------------- - 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). +_SUBJECTS: dict[str, str] = { + "en": "You've been invited to join {tenant_name} on Konstruct", + "es": "Has sido invitado a unirte a {tenant_name} en Konstruct", + "pt": "Voce foi convidado para se juntar a {tenant_name} no Konstruct", +} - 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 - - subject = f"You've been invited to join {tenant_name} on Konstruct" - - text_body = f"""Hi {invitee_name}, +_TEXT_BODIES: dict[str, str] = { + "en": """\ +Hi {invitee_name}, 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. — The Konstruct Team -""" +""", + "es": """\ +Hola {invitee_name}, - html_body = f""" +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": """\ +

You've been invited to join {tenant_name}

Hi {invitee_name},

@@ -83,7 +105,96 @@ If you did not expect this invitation, you can safely ignore this email. you can safely ignore it.

-""" +""", + "es": """\ + + +

Has sido invitado a unirte a {tenant_name}

+

Hola {invitee_name},

+

+ Has sido invitado a unirte a {tenant_name} en + Konstruct, la plataforma de empleados de IA. +

+

+ + Aceptar Invitacion + +

+

+ Esta invitacion vence en 48 horas. Si no esperabas este correo, + puedes ignorarlo de forma segura. +

+ +""", + "pt": """\ + + +

Voce foi convidado para se juntar a {tenant_name}

+

Ola {invitee_name},

+

+ Voce foi convidado para se juntar a {tenant_name} no + Konstruct, a plataforma de funcionarios de IA. +

+

+ + Aceitar Convite + +

+

+ Este convite expira em 48 horas. Se voce nao estava esperando este e-mail, + pode ignora-lo com seguranca. +

+ +""", +} + + +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["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: server.login(settings.smtp_username, settings.smtp_password) 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: logger.exception( "Failed to send invite email to %s (smtp_host=%s)", diff --git a/tests/integration/test_language_preference.py b/tests/integration/test_language_preference.py new file mode 100644 index 0000000..ae86ae3 --- /dev/null +++ b/tests/integration/test_language_preference.py @@ -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) diff --git a/tests/integration/test_templates_i18n.py b/tests/integration/test_templates_i18n.py new file mode 100644 index 0000000..c5c77d4 --- /dev/null +++ b/tests/integration/test_templates_i18n.py @@ -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}" + ) diff --git a/tests/unit/test_portal_auth.py b/tests/unit/test_portal_auth.py index 8b680c8..08f757a 100644 --- a/tests/unit/test_portal_auth.py +++ b/tests/unit/test_portal_auth.py @@ -29,6 +29,7 @@ def _make_user(role: str, email: str = "test@example.com") -> PortalUser: user.email = email user.name = "Test User" user.role = role + user.language = "en" # Real bcrypt hash for password "testpassword" user.hashed_password = bcrypt.hashpw(b"testpassword", bcrypt.gensalt()).decode() user.created_at = datetime.now(tz=timezone.utc)