- 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.
373 lines
12 KiB
Python
Executable File
373 lines
12 KiB
Python
Executable File
# tests/schemas/test_user_schemas.py
|
|
import re
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.schemas.users import UserBase, UserCreate, UserUpdate
|
|
|
|
|
|
class TestPhoneNumberValidation:
|
|
"""Tests for phone number validation in user schemas"""
|
|
|
|
def test_valid_swiss_numbers(self):
|
|
"""Test valid Swiss phone numbers are accepted"""
|
|
# International format
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+41791234567",
|
|
)
|
|
assert user.phone_number == "+41791234567"
|
|
|
|
# Local format
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="0791234567",
|
|
)
|
|
assert user.phone_number == "0791234567"
|
|
|
|
# With formatting characters
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+41 79 123 45 67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+41791234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="079 123 45 67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "0791234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+41-79-123-45-67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+41791234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="079-123-45-67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "0791234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+41 (79) 123 45 67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+41791234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="079 (123) 45 67",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "0791234567"
|
|
|
|
def test_valid_italian_numbers(self):
|
|
"""Test valid Italian phone numbers are accepted"""
|
|
# International format
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+393451234567",
|
|
)
|
|
assert user.phone_number == "+393451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+39345123456",
|
|
)
|
|
assert user.phone_number == "+39345123456"
|
|
|
|
# Local format
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="03451234567",
|
|
)
|
|
assert user.phone_number == "03451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="0345123456789",
|
|
)
|
|
assert user.phone_number == "0345123456789"
|
|
|
|
# With formatting characters
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+39 345 123 4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+393451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="0345 123 4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "03451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+39-345-123-4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+393451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="0345-123-4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "03451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="+39 (345) 123 4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "+393451234567"
|
|
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number="0345 (123) 4567",
|
|
)
|
|
assert re.sub(r"[\s\-\(\)]", "", user.phone_number) == "03451234567"
|
|
|
|
def test_none_phone_number(self):
|
|
"""Test that None is accepted as a valid value (optional phone number)"""
|
|
user = UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number=None,
|
|
)
|
|
assert user.phone_number is None
|
|
|
|
def test_invalid_phone_numbers(self):
|
|
"""Test that invalid phone numbers are rejected"""
|
|
invalid_numbers = [
|
|
# Too short
|
|
"+12",
|
|
"012",
|
|
# Invalid characters
|
|
"+41xyz123456",
|
|
"079abc4567",
|
|
"123-abc-7890",
|
|
"+1(800)CALL-NOW",
|
|
# Completely invalid formats
|
|
"++4412345678", # Double plus
|
|
# Note: "()+41123456" becomes "+41123456" after cleaning, which is valid
|
|
# Empty string
|
|
"",
|
|
# Spaces only
|
|
" ",
|
|
]
|
|
|
|
for number in invalid_numbers:
|
|
with pytest.raises(ValidationError):
|
|
UserBase(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
phone_number=number,
|
|
)
|
|
|
|
def test_phone_validation_in_user_create(self):
|
|
"""Test that phone validation also works in UserCreate schema"""
|
|
# Valid phone number
|
|
user = UserCreate(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
password="Password123!",
|
|
phone_number="+41791234567",
|
|
)
|
|
assert user.phone_number == "+41791234567"
|
|
|
|
# Invalid phone number should raise ValidationError
|
|
with pytest.raises(ValidationError):
|
|
UserCreate(
|
|
email="test@example.com",
|
|
first_name="Test",
|
|
last_name="User",
|
|
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
|