Add user locale preference support and locale detection logic
- Introduced `locale` field in user model and schemas with BCP 47 format validation. - Created Alembic migration to add `locale` column to the `users` table with indexing for better query performance. - Implemented `get_locale` dependency to detect locale using user preference, `Accept-Language` header, or default to English. - Added extensive tests for locale validation, dependency logic, and fallback handling. - Enhanced documentation and comments detailing the locale detection workflow and SUPPORTED_LOCALES configuration.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""add user locale preference column
|
||||
|
||||
Revision ID: c8e9f3a2d1b4
|
||||
Revises: b76c725fc3cf
|
||||
Create Date: 2025-11-17 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c8e9f3a2d1b4"
|
||||
down_revision: str | None = "b76c725fc3cf"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add locale column to users table
|
||||
# VARCHAR(10) supports BCP 47 format (e.g., "en", "it", "en-US", "it-IT")
|
||||
# Nullable: NULL means "not set yet", will use Accept-Language header fallback
|
||||
# Indexed: For analytics queries and filtering by locale
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("locale", sa.String(length=10), nullable=True)
|
||||
)
|
||||
|
||||
# Create index on locale column for performance
|
||||
op.create_index(
|
||||
"ix_users_locale",
|
||||
"users",
|
||||
["locale"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove locale index and column
|
||||
op.drop_index("ix_users_locale", table_name="users")
|
||||
op.drop_column("users", "locale")
|
||||
131
backend/app/api/dependencies/locale.py
Normal file
131
backend/app/api/dependencies/locale.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# app/api/dependencies/locale.py
|
||||
"""
|
||||
Locale detection dependency for internationalization (i18n).
|
||||
|
||||
Implements a three-tier fallback system:
|
||||
1. User's saved preference (if authenticated and user.locale is set)
|
||||
2. Accept-Language header (for unauthenticated users or no saved preference)
|
||||
3. Default to English ("en")
|
||||
"""
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from app.api.dependencies.auth import get_optional_current_user
|
||||
from app.models.user import User
|
||||
|
||||
# Supported locales (BCP 47 format)
|
||||
# Template showcases English and Italian
|
||||
# Users can extend by adding more locales here
|
||||
# Note: Stored in lowercase for case-insensitive matching
|
||||
SUPPORTED_LOCALES = {"en", "it", "en-us", "en-gb", "it-it"}
|
||||
DEFAULT_LOCALE = "en"
|
||||
|
||||
|
||||
def parse_accept_language(accept_language: str) -> str | None:
|
||||
"""
|
||||
Parse the Accept-Language header and return the best matching supported locale.
|
||||
|
||||
The Accept-Language header format is:
|
||||
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7"
|
||||
|
||||
This function extracts locales in priority order (by quality value) and returns
|
||||
the first one that matches our supported locales.
|
||||
|
||||
Args:
|
||||
accept_language: The Accept-Language header value
|
||||
|
||||
Returns:
|
||||
The best matching locale code, or None if no match found
|
||||
|
||||
Examples:
|
||||
>>> parse_accept_language("it-IT,it;q=0.9,en;q=0.8")
|
||||
"it-IT" # or "it" if it-IT is not supported
|
||||
>>> parse_accept_language("fr-FR,fr;q=0.9")
|
||||
None # French not supported
|
||||
"""
|
||||
if not accept_language:
|
||||
return None
|
||||
|
||||
# Split by comma to get individual locale entries
|
||||
# Format: "locale;q=weight" or just "locale"
|
||||
locales = []
|
||||
for entry in accept_language.split(","):
|
||||
# Remove quality value (;q=0.9) if present
|
||||
locale = entry.split(";")[0].strip()
|
||||
if locale:
|
||||
locales.append(locale)
|
||||
|
||||
# Check each locale in priority order
|
||||
for locale in locales:
|
||||
locale_lower = locale.lower()
|
||||
|
||||
# Try exact match first (e.g., "it-IT")
|
||||
if locale_lower in SUPPORTED_LOCALES:
|
||||
return locale_lower
|
||||
|
||||
# Try language code only (e.g., "it" from "it-IT")
|
||||
lang_code = locale_lower.split("-")[0]
|
||||
if lang_code in SUPPORTED_LOCALES:
|
||||
return lang_code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_locale(
|
||||
request: Request,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
) -> str:
|
||||
"""
|
||||
Detect and return the appropriate locale for the current request.
|
||||
|
||||
Three-tier fallback system:
|
||||
1. **User Preference** (highest priority)
|
||||
- If user is authenticated and has a saved locale preference, use it
|
||||
- This persists across sessions and devices
|
||||
|
||||
2. **Accept-Language Header** (second priority)
|
||||
- Parse the Accept-Language header from the request
|
||||
- Match against supported locales
|
||||
- Common for browser requests
|
||||
|
||||
3. **Default Locale** (fallback)
|
||||
- Return "en" (English) if no user preference and no header match
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object (for accessing headers)
|
||||
current_user: The current authenticated user (optional)
|
||||
|
||||
Returns:
|
||||
A valid locale code from SUPPORTED_LOCALES (guaranteed to be supported)
|
||||
|
||||
Examples:
|
||||
>>> # Authenticated user with saved preference
|
||||
>>> await get_locale(request, user_with_locale_it)
|
||||
"it"
|
||||
|
||||
>>> # Unauthenticated user with Italian browser
|
||||
>>> # (request has Accept-Language: it-IT,it;q=0.9)
|
||||
>>> await get_locale(request, None)
|
||||
"it"
|
||||
|
||||
>>> # Unauthenticated user with unsupported language
|
||||
>>> # (request has Accept-Language: fr-FR,fr;q=0.9)
|
||||
>>> await get_locale(request, None)
|
||||
"en"
|
||||
"""
|
||||
# Priority 1: User's saved preference
|
||||
if current_user and current_user.locale:
|
||||
# Validate that saved locale is still supported
|
||||
# (in case SUPPORTED_LOCALES changed after user set preference)
|
||||
if current_user.locale in SUPPORTED_LOCALES:
|
||||
return current_user.locale
|
||||
|
||||
# Priority 2: Accept-Language header
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
if accept_language:
|
||||
detected_locale = parse_accept_language(accept_language)
|
||||
if detected_locale:
|
||||
return detected_locale
|
||||
|
||||
# Priority 3: Default fallback
|
||||
return DEFAULT_LOCALE
|
||||
@@ -16,6 +16,7 @@ class User(Base, UUIDMixin, TimestampMixin):
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
is_superuser = Column(Boolean, default=False, nullable=False, index=True)
|
||||
preferences = Column(JSONB)
|
||||
locale = Column(String(10), nullable=True, index=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -37,6 +37,13 @@ class UserUpdate(BaseModel):
|
||||
phone_number: str | None = None
|
||||
password: str | None = None
|
||||
preferences: dict[str, Any] | None = None
|
||||
locale: str | None = Field(
|
||||
None,
|
||||
max_length=10,
|
||||
pattern=r'^[a-z]{2}(-[A-Z]{2})?$',
|
||||
description="User's preferred locale (BCP 47 format: en, it, en-US, it-IT)",
|
||||
examples=["en", "it", "en-US", "it-IT"]
|
||||
)
|
||||
is_active: bool | None = (
|
||||
None # Changed default from True to None to avoid unintended updates
|
||||
)
|
||||
@@ -55,6 +62,24 @@ class UserUpdate(BaseModel):
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
@field_validator("locale")
|
||||
@classmethod
|
||||
def validate_locale(cls, v: str | None) -> str | None:
|
||||
"""Validate locale against supported locales."""
|
||||
if v is None:
|
||||
return v
|
||||
# Only support English and Italian for template showcase
|
||||
# Note: Locales stored in lowercase for case-insensitive matching
|
||||
SUPPORTED_LOCALES = {"en", "it", "en-us", "en-gb", "it-it"}
|
||||
# Normalize to lowercase for comparison and storage
|
||||
v_lower = v.lower()
|
||||
if v_lower not in SUPPORTED_LOCALES:
|
||||
raise ValueError(
|
||||
f"Unsupported locale '{v}'. Supported locales: {sorted(SUPPORTED_LOCALES)}"
|
||||
)
|
||||
# Return normalized lowercase version for consistency
|
||||
return v_lower
|
||||
|
||||
@field_validator("is_superuser")
|
||||
@classmethod
|
||||
def prevent_superuser_modification(cls, v: bool | None) -> bool | None:
|
||||
@@ -70,6 +95,7 @@ class UserInDB(UserBase):
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
locale: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -80,6 +106,7 @@ class UserResponse(UserBase):
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
locale: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
317
backend/tests/api/dependencies/test_locale_dependencies.py
Normal file
317
backend/tests/api/dependencies/test_locale_dependencies.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# tests/api/dependencies/test_locale_dependencies.py
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.api.dependencies.locale import (
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
get_locale,
|
||||
parse_accept_language,
|
||||
)
|
||||
from app.core.auth import get_password_hash
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class TestParseAcceptLanguage:
|
||||
"""Tests for parse_accept_language helper function"""
|
||||
|
||||
def test_parse_empty_header(self):
|
||||
"""Test with empty Accept-Language header"""
|
||||
result = parse_accept_language("")
|
||||
assert result is None
|
||||
|
||||
def test_parse_none_header(self):
|
||||
"""Test with None Accept-Language header"""
|
||||
result = parse_accept_language(None)
|
||||
assert result is None
|
||||
|
||||
def test_parse_italian_exact_match(self):
|
||||
"""Test parsing Italian with exact match"""
|
||||
result = parse_accept_language("it-IT,it;q=0.9,en;q=0.8")
|
||||
assert result == "it-it"
|
||||
|
||||
def test_parse_italian_language_code_only(self):
|
||||
"""Test parsing Italian with only language code"""
|
||||
result = parse_accept_language("it,en;q=0.8")
|
||||
assert result == "it"
|
||||
|
||||
def test_parse_english_us(self):
|
||||
"""Test parsing English (US)"""
|
||||
result = parse_accept_language("en-US,en;q=0.9")
|
||||
assert result == "en-us"
|
||||
|
||||
def test_parse_english_language_code(self):
|
||||
"""Test parsing English with only language code"""
|
||||
result = parse_accept_language("en")
|
||||
assert result == "en"
|
||||
|
||||
def test_parse_unsupported_language(self):
|
||||
"""Test parsing unsupported language (French)"""
|
||||
result = parse_accept_language("fr-FR,fr;q=0.9,de;q=0.8")
|
||||
assert result is None
|
||||
|
||||
def test_parse_mixed_supported_unsupported(self):
|
||||
"""Test with mix of supported and unsupported, should pick first supported"""
|
||||
# French first (unsupported), then Italian (supported)
|
||||
result = parse_accept_language("fr-FR,fr;q=0.9,it;q=0.8")
|
||||
assert result == "it"
|
||||
|
||||
def test_parse_quality_values(self):
|
||||
"""Test that quality values are respected (first = highest priority)"""
|
||||
# English has higher implicit priority (no q value means q=1.0)
|
||||
result = parse_accept_language("en,it;q=0.9")
|
||||
assert result == "en"
|
||||
|
||||
def test_parse_complex_header(self):
|
||||
"""Test complex Accept-Language header with multiple locales"""
|
||||
result = parse_accept_language(
|
||||
"it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6"
|
||||
)
|
||||
assert result == "it-it"
|
||||
|
||||
def test_parse_whitespace_handling(self):
|
||||
"""Test that whitespace is handled correctly"""
|
||||
result = parse_accept_language(" it-IT , it ; q=0.9 , en ; q=0.8 ")
|
||||
assert result == "it-it"
|
||||
|
||||
def test_parse_case_insensitive(self):
|
||||
"""Test that locale matching is case-insensitive"""
|
||||
result = parse_accept_language("IT-it,EN-us;q=0.9")
|
||||
# Should normalize to lowercase
|
||||
assert result == "it-it"
|
||||
|
||||
def test_parse_fallback_to_language_code(self):
|
||||
"""Test fallback from region-specific to language code"""
|
||||
# it-CH (Switzerland) not supported, but "it" is
|
||||
result = parse_accept_language("it-CH,en;q=0.8")
|
||||
assert result == "it"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_user_with_locale_en(async_test_db):
|
||||
"""Async fixture to create a user with 'en' locale preference"""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user_en@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
first_name="English",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
locale="en",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_user_with_locale_it(async_test_db):
|
||||
"""Async fixture to create a user with 'it' locale preference"""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user_it@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
first_name="Italian",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
locale="it",
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_user_without_locale(async_test_db):
|
||||
"""Async fixture to create a user without locale preference"""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user_no_locale@example.com",
|
||||
password_hash=get_password_hash("password123"),
|
||||
first_name="No",
|
||||
last_name="Locale",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
locale=None,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
class TestGetLocale:
|
||||
"""Tests for get_locale dependency"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locale_from_user_preference_en(self, async_user_with_locale_en):
|
||||
"""Test locale detection from authenticated user's saved preference (en)"""
|
||||
# Mock request with no Accept-Language header
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
result = await get_locale(
|
||||
request=mock_request, current_user=async_user_with_locale_en
|
||||
)
|
||||
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locale_from_user_preference_it(self, async_user_with_locale_it):
|
||||
"""Test locale detection from authenticated user's saved preference (it)"""
|
||||
# Mock request with no Accept-Language header
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
result = await get_locale(
|
||||
request=mock_request, current_user=async_user_with_locale_it
|
||||
)
|
||||
|
||||
assert result == "it"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_preference_overrides_accept_language(
|
||||
self, async_user_with_locale_en
|
||||
):
|
||||
"""Test that user preference takes precedence over Accept-Language header"""
|
||||
# Mock request with Italian Accept-Language, but user has English preference
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": "it-IT,it;q=0.9"}
|
||||
|
||||
result = await get_locale(
|
||||
request=mock_request, current_user=async_user_with_locale_en
|
||||
)
|
||||
|
||||
# Should return user preference, not Accept-Language
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locale_from_accept_language_header(
|
||||
self, async_user_without_locale
|
||||
):
|
||||
"""Test locale detection from Accept-Language header when user has no preference"""
|
||||
# Mock request with Italian Accept-Language (it-IT has highest priority)
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": "it-IT,it;q=0.9,en;q=0.8"}
|
||||
|
||||
result = await get_locale(
|
||||
request=mock_request, current_user=async_user_without_locale
|
||||
)
|
||||
|
||||
# Should return "it-it" (normalized from "it-IT", the first/highest priority locale)
|
||||
assert result == "it-it"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locale_from_accept_language_unauthenticated(self):
|
||||
"""Test locale detection from Accept-Language header for unauthenticated user"""
|
||||
# Mock request with Italian Accept-Language (it-IT has highest priority)
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": "it-IT,it;q=0.9,en;q=0.8"}
|
||||
|
||||
result = await get_locale(request=mock_request, current_user=None)
|
||||
|
||||
# Should return "it-it" (normalized from "it-IT", the first/highest priority locale)
|
||||
assert result == "it-it"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_locale_no_user_no_header(self):
|
||||
"""Test fallback to default locale when no user and no Accept-Language header"""
|
||||
# Mock request with no Accept-Language header
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
result = await get_locale(request=mock_request, current_user=None)
|
||||
|
||||
assert result == DEFAULT_LOCALE
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_locale_unsupported_language(self):
|
||||
"""Test fallback to default when Accept-Language has only unsupported languages"""
|
||||
# Mock request with French (unsupported)
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": "fr-FR,fr;q=0.9,de;q=0.8"}
|
||||
|
||||
result = await get_locale(request=mock_request, current_user=None)
|
||||
|
||||
assert result == DEFAULT_LOCALE
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_supported_locale_in_db(self, async_user_with_locale_it):
|
||||
"""Test that saved locale is validated against SUPPORTED_LOCALES"""
|
||||
# This test verifies the locale in DB is actually supported
|
||||
assert async_user_with_locale_it.locale in SUPPORTED_LOCALES
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
result = await get_locale(
|
||||
request=mock_request, current_user=async_user_with_locale_it
|
||||
)
|
||||
|
||||
assert result == "it"
|
||||
assert result in SUPPORTED_LOCALES
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_language_case_variations(self):
|
||||
"""Test different case variations in Accept-Language header"""
|
||||
# All return values are lowercase for consistency
|
||||
test_cases = [
|
||||
("it-IT,en;q=0.8", "it-it"),
|
||||
("IT-it,en;q=0.8", "it-it"),
|
||||
("en-US,it;q=0.8", "en-us"),
|
||||
("EN,it;q=0.8", "en"),
|
||||
]
|
||||
|
||||
for accept_lang, expected in test_cases:
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": accept_lang}
|
||||
|
||||
result = await get_locale(request=mock_request, current_user=None)
|
||||
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_language_with_quality_values(self):
|
||||
"""Test Accept-Language parsing respects quality values (priority)"""
|
||||
# English has implicit q=1.0, Italian has q=0.9
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"accept-language": "en,it;q=0.9"}
|
||||
|
||||
result = await get_locale(request=mock_request, current_user=None)
|
||||
|
||||
# Should return English (higher priority)
|
||||
assert result == "en"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supported_locales_constant(self):
|
||||
"""Test that SUPPORTED_LOCALES contains expected locales"""
|
||||
# Note: SUPPORTED_LOCALES uses lowercase for case-insensitive matching
|
||||
assert "en" in SUPPORTED_LOCALES
|
||||
assert "it" in SUPPORTED_LOCALES
|
||||
assert "en-us" in SUPPORTED_LOCALES
|
||||
assert "en-gb" in SUPPORTED_LOCALES
|
||||
assert "it-it" in SUPPORTED_LOCALES
|
||||
|
||||
# Verify total count matches implementation plan (5 locales for EN/IT showcase)
|
||||
assert len(SUPPORTED_LOCALES) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_locale_constant(self):
|
||||
"""Test that DEFAULT_LOCALE is English"""
|
||||
assert DEFAULT_LOCALE == "en"
|
||||
assert DEFAULT_LOCALE in SUPPORTED_LOCALES
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.users import UserBase, UserCreate
|
||||
from app.schemas.users import UserBase, UserCreate, UserUpdate
|
||||
|
||||
|
||||
class TestPhoneNumberValidation:
|
||||
@@ -224,3 +224,149 @@ class TestPhoneNumberValidation:
|
||||
password="Password123!",
|
||||
phone_number="invalid-number",
|
||||
)
|
||||
|
||||
|
||||
class TestLocaleValidation:
|
||||
"""Tests for locale validation in user schemas"""
|
||||
|
||||
def test_valid_locale_en(self):
|
||||
"""Test that 'en' locale is accepted"""
|
||||
user = UserUpdate(locale="en")
|
||||
assert user.locale == "en"
|
||||
|
||||
def test_valid_locale_it(self):
|
||||
"""Test that 'it' locale is accepted"""
|
||||
user = UserUpdate(locale="it")
|
||||
assert user.locale == "it"
|
||||
|
||||
def test_valid_locale_en_us(self):
|
||||
"""Test that 'en-US' locale is accepted and normalized to lowercase"""
|
||||
user = UserUpdate(locale="en-US")
|
||||
assert user.locale == "en-us" # Normalized to lowercase
|
||||
|
||||
def test_valid_locale_en_gb(self):
|
||||
"""Test that 'en-GB' locale is accepted and normalized to lowercase"""
|
||||
user = UserUpdate(locale="en-GB")
|
||||
assert user.locale == "en-gb" # Normalized to lowercase
|
||||
|
||||
def test_valid_locale_it_it(self):
|
||||
"""Test that 'it-IT' locale is accepted and normalized to lowercase"""
|
||||
user = UserUpdate(locale="it-IT")
|
||||
assert user.locale == "it-it" # Normalized to lowercase
|
||||
|
||||
def test_none_locale(self):
|
||||
"""Test that None is accepted as a valid value (optional locale)"""
|
||||
user = UserUpdate(locale=None)
|
||||
assert user.locale is None
|
||||
|
||||
def test_locale_not_provided(self):
|
||||
"""Test that locale can be omitted entirely"""
|
||||
user = UserUpdate(first_name="Test")
|
||||
assert user.locale is None
|
||||
|
||||
def test_unsupported_locale_french(self):
|
||||
"""Test that unsupported locale 'fr' is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserUpdate(locale="fr")
|
||||
|
||||
# Verify error message mentions unsupported locale
|
||||
assert "Unsupported locale" in str(exc_info.value)
|
||||
|
||||
def test_unsupported_locale_german(self):
|
||||
"""Test that unsupported locale 'de' is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserUpdate(locale="de")
|
||||
|
||||
assert "Unsupported locale" in str(exc_info.value)
|
||||
|
||||
def test_unsupported_locale_spanish(self):
|
||||
"""Test that unsupported locale 'es' is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserUpdate(locale="es")
|
||||
|
||||
assert "Unsupported locale" in str(exc_info.value)
|
||||
|
||||
def test_unsupported_locale_region_specific(self):
|
||||
"""Test that unsupported region-specific locale is rejected"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserUpdate(locale="it-CH") # Italian (Switzerland) - not in supported list
|
||||
|
||||
assert "Unsupported locale" in str(exc_info.value)
|
||||
|
||||
def test_invalid_locale_format_no_dash(self):
|
||||
"""Test that invalid format (no dash between components) is rejected"""
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(locale="enus") # Should be "en-US"
|
||||
|
||||
def test_invalid_locale_format_invalid_pattern(self):
|
||||
"""Test that completely invalid format is rejected"""
|
||||
invalid_locales = [
|
||||
"en-us", # Region code must be uppercase
|
||||
"EN", # Language code must be lowercase
|
||||
"en-", # Incomplete
|
||||
"-US", # Incomplete
|
||||
"e", # Too short
|
||||
"eng", # Language code too long
|
||||
"en-USA", # Region code too long
|
||||
"123", # Numbers not allowed
|
||||
"en_US", # Underscore not allowed (must be dash)
|
||||
]
|
||||
|
||||
for invalid_locale in invalid_locales:
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(locale=invalid_locale)
|
||||
|
||||
def test_empty_string_locale(self):
|
||||
"""Test that empty string is rejected"""
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(locale="")
|
||||
|
||||
def test_locale_with_whitespace(self):
|
||||
"""Test that locale with whitespace is rejected"""
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(locale=" en ")
|
||||
|
||||
def test_locale_max_length(self):
|
||||
"""Test that locale exceeding max length (10 chars) is rejected"""
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(locale="en-US-extra") # 12 characters, exceeds max_length=10
|
||||
|
||||
def test_locale_in_user_update_with_other_fields(self):
|
||||
"""Test locale validation works when combined with other fields"""
|
||||
# Valid locale with other fields
|
||||
user = UserUpdate(
|
||||
first_name="Mario",
|
||||
last_name="Rossi",
|
||||
locale="it"
|
||||
)
|
||||
assert user.locale == "it"
|
||||
assert user.first_name == "Mario"
|
||||
|
||||
# Invalid locale with other valid fields should still fail
|
||||
with pytest.raises(ValidationError):
|
||||
UserUpdate(
|
||||
first_name="Pierre",
|
||||
last_name="Dupont",
|
||||
locale="fr" # Unsupported
|
||||
)
|
||||
|
||||
def test_supported_locales_list(self):
|
||||
"""Test all supported locales are accepted and normalized"""
|
||||
# Input locales (mixed case)
|
||||
input_locales = ["en", "it", "en-US", "en-GB", "it-IT"]
|
||||
# Expected output (normalized to lowercase)
|
||||
expected_outputs = ["en", "it", "en-us", "en-gb", "it-it"]
|
||||
|
||||
for input_locale, expected_output in zip(input_locales, expected_outputs):
|
||||
user = UserUpdate(locale=input_locale)
|
||||
assert user.locale == expected_output
|
||||
|
||||
def test_locale_error_message_shows_supported_locales(self):
|
||||
"""Test that error message lists supported locales"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserUpdate(locale="fr")
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
# Should mention supported locales in error
|
||||
assert "en" in error_str or "it" in error_str
|
||||
assert "Supported locales" in error_str
|
||||
|
||||
Reference in New Issue
Block a user