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:
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
186
tests/integration/test_language_preference.py
Normal file
186
tests/integration/test_language_preference.py
Normal 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)
|
||||||
226
tests/integration/test_templates_i18n.py
Normal file
226
tests/integration/test_templates_i18n.py
Normal 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}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user