Add comprehensive tests for schemas, validators, and exception handlers
- **Schemas:** Introduce unit tests for `OrganizationBase`, `OrganizationCreate`, and `OrganizationUpdate` schemas. Validate edge cases for slug and name validation. - **Validators:** Add tests for `validate_password_strength`, `validate_phone_number`, `validate_email_format`, and `validate_slug`. Cover edge cases, boundary conditions, and defensive code paths. - **Exception Handlers:** Ensure proper error handling in organization, user, and session CRUD operations. Mock database errors and validate exception responses. - Include test cases to verify robust behavior, normalization, and failure scenarios across schema and validation logic.
This commit is contained in:
187
backend/tests/schemas/test_organizations.py
Normal file
187
backend/tests/schemas/test_organizations.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Tests for organization schemas (app/schemas/organizations.py).
|
||||
|
||||
Covers Pydantic validators for:
|
||||
- Slug validation (lines 26, 28, 30, 32, 62-70)
|
||||
- Name validation (lines 40, 77)
|
||||
"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.organizations import (
|
||||
OrganizationBase,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
|
||||
|
||||
class TestOrganizationBaseValidators:
|
||||
"""Test validators in OrganizationBase schema."""
|
||||
|
||||
def test_valid_organization_base(self):
|
||||
"""Test that valid data passes validation."""
|
||||
org = OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug="test-org"
|
||||
)
|
||||
assert org.name == "Test Organization"
|
||||
assert org.slug == "test-org"
|
||||
|
||||
def test_slug_none_returns_none(self):
|
||||
"""Test that None slug is allowed (covers line 26)."""
|
||||
org = OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug=None
|
||||
)
|
||||
assert org.slug is None
|
||||
|
||||
def test_slug_invalid_characters_rejected(self):
|
||||
"""Test slug with invalid characters is rejected (covers line 28)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug="Test_Org!" # Uppercase and special chars
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("lowercase letters, numbers, and hyphens" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_slug_starts_with_hyphen_rejected(self):
|
||||
"""Test slug starting with hyphen is rejected (covers line 30)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug="-test-org"
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot start or end with a hyphen" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_slug_ends_with_hyphen_rejected(self):
|
||||
"""Test slug ending with hyphen is rejected (covers line 30)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug="test-org-"
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot start or end with a hyphen" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_slug_consecutive_hyphens_rejected(self):
|
||||
"""Test slug with consecutive hyphens is rejected (covers line 32)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationBase(
|
||||
name="Test Organization",
|
||||
slug="test--org"
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot contain consecutive hyphens" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_name_whitespace_only_rejected(self):
|
||||
"""Test whitespace-only name is rejected (covers line 40)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationBase(
|
||||
name=" ",
|
||||
slug="test-org"
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("name cannot be empty" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_name_trimmed(self):
|
||||
"""Test that name is trimmed."""
|
||||
org = OrganizationBase(
|
||||
name=" Test Organization ",
|
||||
slug="test-org"
|
||||
)
|
||||
assert org.name == "Test Organization"
|
||||
|
||||
|
||||
class TestOrganizationCreateValidators:
|
||||
"""Test OrganizationCreate schema inherits validators."""
|
||||
|
||||
def test_valid_organization_create(self):
|
||||
"""Test that valid data passes validation."""
|
||||
org = OrganizationCreate(
|
||||
name="Test Organization",
|
||||
slug="test-org"
|
||||
)
|
||||
assert org.name == "Test Organization"
|
||||
assert org.slug == "test-org"
|
||||
|
||||
def test_slug_validation_inherited(self):
|
||||
"""Test that slug validation is inherited from base."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationCreate(
|
||||
name="Test",
|
||||
slug="Invalid_Slug!"
|
||||
)
|
||||
errors = exc_info.value.errors()
|
||||
assert any("lowercase letters, numbers, and hyphens" in str(e['msg']) for e in errors)
|
||||
|
||||
|
||||
class TestOrganizationUpdateValidators:
|
||||
"""Test validators in OrganizationUpdate schema."""
|
||||
|
||||
def test_valid_organization_update(self):
|
||||
"""Test that valid update data passes validation."""
|
||||
org = OrganizationUpdate(
|
||||
name="Updated Name",
|
||||
slug="updated-slug"
|
||||
)
|
||||
assert org.name == "Updated Name"
|
||||
assert org.slug == "updated-slug"
|
||||
|
||||
def test_slug_none_returns_none(self):
|
||||
"""Test that None slug is allowed in update (covers line 62)."""
|
||||
org = OrganizationUpdate(slug=None)
|
||||
assert org.slug is None
|
||||
|
||||
def test_update_slug_invalid_characters_rejected(self):
|
||||
"""Test update slug with invalid characters is rejected (covers line 64)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationUpdate(slug="Test_Org!")
|
||||
errors = exc_info.value.errors()
|
||||
assert any("lowercase letters, numbers, and hyphens" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_update_slug_starts_with_hyphen_rejected(self):
|
||||
"""Test update slug starting with hyphen is rejected (covers line 66)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationUpdate(slug="-test-org")
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot start or end with a hyphen" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_update_slug_ends_with_hyphen_rejected(self):
|
||||
"""Test update slug ending with hyphen is rejected (covers line 66)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationUpdate(slug="test-org-")
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot start or end with a hyphen" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_update_slug_consecutive_hyphens_rejected(self):
|
||||
"""Test update slug with consecutive hyphens is rejected (covers line 68)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationUpdate(slug="test--org")
|
||||
errors = exc_info.value.errors()
|
||||
assert any("cannot contain consecutive hyphens" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_update_name_whitespace_only_rejected(self):
|
||||
"""Test whitespace-only name in update is rejected (covers line 77)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
OrganizationUpdate(name=" ")
|
||||
errors = exc_info.value.errors()
|
||||
assert any("name cannot be empty" in str(e['msg']) for e in errors)
|
||||
|
||||
def test_update_name_none_allowed(self):
|
||||
"""Test that None name is allowed in update."""
|
||||
org = OrganizationUpdate(name=None)
|
||||
assert org.name is None
|
||||
|
||||
def test_update_name_trimmed(self):
|
||||
"""Test that update name is trimmed."""
|
||||
org = OrganizationUpdate(name=" Updated Name ")
|
||||
assert org.name == "Updated Name"
|
||||
|
||||
def test_partial_update(self):
|
||||
"""Test that partial updates work (all fields optional)."""
|
||||
org = OrganizationUpdate(name="New Name")
|
||||
assert org.name == "New Name"
|
||||
assert org.slug is None
|
||||
assert org.description is None
|
||||
216
backend/tests/schemas/test_validators.py
Normal file
216
backend/tests/schemas/test_validators.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Tests for schema validators (app/schemas/validators.py).
|
||||
|
||||
Covers all edge cases in validation functions:
|
||||
- validate_password_strength
|
||||
- validate_phone_number (lines 115, 119)
|
||||
- validate_email_format (line 148)
|
||||
- validate_slug (lines 170-183)
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.schemas.validators import (
|
||||
validate_password_strength,
|
||||
validate_phone_number,
|
||||
validate_email_format,
|
||||
validate_slug,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswordStrengthValidator:
|
||||
"""Test password strength validation."""
|
||||
|
||||
def test_valid_strong_password(self):
|
||||
"""Test that a strong password passes validation."""
|
||||
password = "MySecureP@ss123"
|
||||
result = validate_password_strength(password)
|
||||
assert result == password
|
||||
|
||||
def test_password_too_short(self):
|
||||
"""Test that password shorter than 12 characters is rejected."""
|
||||
with pytest.raises(ValueError, match="at least 12 characters long"):
|
||||
validate_password_strength("Short1!")
|
||||
|
||||
def test_common_password_rejected(self):
|
||||
"""Test that common passwords are rejected."""
|
||||
# "password1234" is in COMMON_PASSWORDS and is 12 chars
|
||||
# Common password check happens before character type checks
|
||||
with pytest.raises(ValueError, match="too common"):
|
||||
validate_password_strength("password1234")
|
||||
|
||||
def test_password_missing_lowercase(self):
|
||||
"""Test that password without lowercase is rejected."""
|
||||
with pytest.raises(ValueError, match="at least one lowercase letter"):
|
||||
validate_password_strength("ALLUPPERCASE123!")
|
||||
|
||||
def test_password_missing_uppercase(self):
|
||||
"""Test that password without uppercase is rejected."""
|
||||
with pytest.raises(ValueError, match="at least one uppercase letter"):
|
||||
validate_password_strength("alllowercase123!")
|
||||
|
||||
def test_password_missing_digit(self):
|
||||
"""Test that password without digit is rejected."""
|
||||
with pytest.raises(ValueError, match="at least one digit"):
|
||||
validate_password_strength("NoDigitsHere!")
|
||||
|
||||
def test_password_missing_special_char(self):
|
||||
"""Test that password without special character is rejected."""
|
||||
with pytest.raises(ValueError, match="at least one special character"):
|
||||
validate_password_strength("NoSpecialChar123")
|
||||
|
||||
|
||||
class TestPhoneNumberValidator:
|
||||
"""Test phone number validation."""
|
||||
|
||||
def test_valid_international_format(self):
|
||||
"""Test valid international phone number."""
|
||||
result = validate_phone_number("+12345678901")
|
||||
assert result == "+12345678901"
|
||||
|
||||
def test_valid_local_format(self):
|
||||
"""Test valid local phone number."""
|
||||
result = validate_phone_number("0123456789")
|
||||
assert result == "0123456789"
|
||||
|
||||
def test_valid_with_formatting(self):
|
||||
"""Test phone number with formatting characters."""
|
||||
result = validate_phone_number("+1 (555) 123-4567")
|
||||
assert result == "+15551234567"
|
||||
|
||||
def test_none_returns_none(self):
|
||||
"""Test that None input returns None."""
|
||||
result = validate_phone_number(None)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
"""Test that empty string is rejected."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_phone_number("")
|
||||
|
||||
def test_whitespace_only_rejected(self):
|
||||
"""Test that whitespace-only string is rejected."""
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
validate_phone_number(" ")
|
||||
|
||||
def test_invalid_prefix_rejected(self):
|
||||
"""Test that invalid prefix is rejected."""
|
||||
with pytest.raises(ValueError, match="must start with \\+ or 0"):
|
||||
validate_phone_number("12345678901")
|
||||
|
||||
def test_too_short_rejected(self):
|
||||
"""Test that too-short phone number is rejected."""
|
||||
with pytest.raises(ValueError, match="must start with \\+ or 0"):
|
||||
validate_phone_number("+1234567") # Only 7 digits after +
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
"""Test that too-long phone number is rejected."""
|
||||
with pytest.raises(ValueError, match="must start with \\+ or 0"):
|
||||
validate_phone_number("+123456789012345") # 15 digits after +
|
||||
|
||||
def test_multiple_plus_symbols_rejected(self):
|
||||
"""Test phone number with multiple + symbols.
|
||||
|
||||
Note: Line 115 is defensive code - the regex check at line 110 catches this first.
|
||||
The regex ^(?:\+[0-9]{8,14}|0[0-9]{8,14})$ only allows + at the start.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="must start with \\+ or 0 followed by 8-14 digits"):
|
||||
validate_phone_number("+1234+5678901")
|
||||
|
||||
def test_non_digit_after_prefix_rejected(self):
|
||||
"""Test phone number with non-digit characters after prefix.
|
||||
|
||||
Note: Line 119 is defensive code - the regex check at line 110 catches this first.
|
||||
The regex requires all digits after the prefix.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="must start with \\+ or 0"):
|
||||
validate_phone_number("+123abc45678")
|
||||
|
||||
|
||||
class TestEmailFormatValidator:
|
||||
"""Test email format validation."""
|
||||
|
||||
def test_valid_email_lowercase(self):
|
||||
"""Test valid lowercase email."""
|
||||
result = validate_email_format("test@example.com")
|
||||
assert result == "test@example.com"
|
||||
|
||||
def test_email_normalized_to_lowercase(self):
|
||||
"""Test email is normalized to lowercase (covers line 148)."""
|
||||
result = validate_email_format("Test@Example.COM")
|
||||
assert result == "test@example.com"
|
||||
|
||||
def test_email_with_uppercase_domain(self):
|
||||
"""Test email with uppercase domain is normalized."""
|
||||
result = validate_email_format("user@EXAMPLE.COM")
|
||||
assert result == "user@example.com"
|
||||
|
||||
|
||||
class TestSlugValidator:
|
||||
"""Test slug validation."""
|
||||
|
||||
def test_valid_slug_lowercase_letters(self):
|
||||
"""Test valid slug with lowercase letters."""
|
||||
result = validate_slug("test-slug")
|
||||
assert result == "test-slug"
|
||||
|
||||
def test_valid_slug_with_numbers(self):
|
||||
"""Test valid slug with numbers."""
|
||||
result = validate_slug("test-123")
|
||||
assert result == "test-123"
|
||||
|
||||
def test_valid_slug_minimal_length(self):
|
||||
"""Test valid slug with minimal length (2 characters)."""
|
||||
result = validate_slug("ab")
|
||||
assert result == "ab"
|
||||
|
||||
def test_empty_slug_rejected(self):
|
||||
"""Test empty slug is rejected (covers line 170)."""
|
||||
with pytest.raises(ValueError, match="at least 2 characters long"):
|
||||
validate_slug("")
|
||||
|
||||
def test_single_character_slug_rejected(self):
|
||||
"""Test single character slug is rejected (covers line 170)."""
|
||||
with pytest.raises(ValueError, match="at least 2 characters long"):
|
||||
validate_slug("a")
|
||||
|
||||
def test_slug_too_long_rejected(self):
|
||||
"""Test slug longer than 50 characters is rejected (covers line 173)."""
|
||||
long_slug = "a" * 51
|
||||
with pytest.raises(ValueError, match="at most 50 characters long"):
|
||||
validate_slug(long_slug)
|
||||
|
||||
def test_slug_max_length_accepted(self):
|
||||
"""Test slug with exactly 50 characters is accepted."""
|
||||
max_slug = "a" * 50
|
||||
result = validate_slug(max_slug)
|
||||
assert result == max_slug
|
||||
|
||||
def test_slug_starts_with_hyphen_rejected(self):
|
||||
"""Test slug starting with hyphen is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="cannot start or end with a hyphen"):
|
||||
validate_slug("-test")
|
||||
|
||||
def test_slug_ends_with_hyphen_rejected(self):
|
||||
"""Test slug ending with hyphen is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="cannot start or end with a hyphen"):
|
||||
validate_slug("test-")
|
||||
|
||||
def test_slug_consecutive_hyphens_rejected(self):
|
||||
"""Test slug with consecutive hyphens is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="cannot contain consecutive hyphens"):
|
||||
validate_slug("test--slug")
|
||||
|
||||
def test_slug_uppercase_letters_rejected(self):
|
||||
"""Test slug with uppercase letters is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="only contain lowercase letters"):
|
||||
validate_slug("Test-Slug")
|
||||
|
||||
def test_slug_special_characters_rejected(self):
|
||||
"""Test slug with special characters is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="only contain lowercase letters"):
|
||||
validate_slug("test_slug")
|
||||
|
||||
def test_slug_spaces_rejected(self):
|
||||
"""Test slug with spaces is rejected (covers line 177)."""
|
||||
with pytest.raises(ValueError, match="only contain lowercase letters"):
|
||||
validate_slug("test slug")
|
||||
Reference in New Issue
Block a user