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:
Felipe Cardoso
2025-11-17 19:47:50 +01:00
parent 3001484948
commit 68e04a911a
6 changed files with 665 additions and 1 deletions

View File

@@ -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