2025-11-21 19:56:03 +01:00
|
|
|
"""Unit tests for validation logic."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2025-12-04 12:15:55 +01:00
|
|
|
from src.validation import (
|
|
|
|
|
ValidationError,
|
|
|
|
|
validate_email,
|
|
|
|
|
validate_phone,
|
|
|
|
|
validate_member_type_requirements,
|
|
|
|
|
)
|
2025-11-21 19:56:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmailValidation:
|
|
|
|
|
"""Tests for validate_email function."""
|
|
|
|
|
|
|
|
|
|
def test_valid_email(self):
|
|
|
|
|
"""Test that valid email passes validation."""
|
|
|
|
|
validate_email("test@example.com")
|
|
|
|
|
# No exception = pass
|
|
|
|
|
|
|
|
|
|
def test_valid_email_with_subdomain(self):
|
|
|
|
|
"""Test that email with subdomain passes validation."""
|
|
|
|
|
validate_email("user@mail.example.com")
|
|
|
|
|
|
|
|
|
|
def test_valid_email_with_plus(self):
|
|
|
|
|
"""Test that email with plus sign passes validation."""
|
|
|
|
|
validate_email("user+tag@example.com")
|
|
|
|
|
|
|
|
|
|
def test_invalid_email_no_at(self):
|
|
|
|
|
"""Test that email without @ raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid email format"):
|
|
|
|
|
validate_email("notanemail")
|
|
|
|
|
|
|
|
|
|
def test_invalid_email_no_domain(self):
|
|
|
|
|
"""Test that email without domain raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid email format"):
|
|
|
|
|
validate_email("user@")
|
|
|
|
|
|
|
|
|
|
def test_invalid_email_no_tld(self):
|
|
|
|
|
"""Test that email without TLD raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid email format"):
|
|
|
|
|
validate_email("user@example")
|
|
|
|
|
|
|
|
|
|
def test_none_is_allowed(self):
|
|
|
|
|
"""Test that None passes validation (optional field)."""
|
|
|
|
|
validate_email(None)
|
|
|
|
|
# No exception = pass
|
|
|
|
|
|
|
|
|
|
def test_empty_string_is_allowed(self):
|
|
|
|
|
"""Test that empty string passes validation (optional field)."""
|
|
|
|
|
validate_email("")
|
|
|
|
|
# No exception = pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPhoneValidation:
|
|
|
|
|
"""Tests for validate_phone function."""
|
|
|
|
|
|
|
|
|
|
def test_valid_phone_with_plus(self):
|
|
|
|
|
"""Test that valid E.164 phone with + passes validation."""
|
|
|
|
|
validate_phone("+41791234567")
|
|
|
|
|
|
|
|
|
|
def test_valid_phone_without_plus(self):
|
|
|
|
|
"""Test that valid E.164 phone without + passes validation."""
|
|
|
|
|
validate_phone("41791234567")
|
|
|
|
|
|
|
|
|
|
def test_valid_us_phone(self):
|
|
|
|
|
"""Test that valid US phone passes validation."""
|
|
|
|
|
validate_phone("+14155551234")
|
|
|
|
|
|
|
|
|
|
def test_invalid_phone_with_invalid_chars(self):
|
|
|
|
|
"""Test that phone with invalid characters raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid phone format"):
|
|
|
|
|
validate_phone("abc123")
|
|
|
|
|
|
|
|
|
|
def test_invalid_phone_too_long(self):
|
|
|
|
|
"""Test that phone too long raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid phone format"):
|
|
|
|
|
validate_phone("+123456789012345678") # More than 15 digits
|
|
|
|
|
|
|
|
|
|
def test_invalid_phone_starts_with_zero(self):
|
|
|
|
|
"""Test that phone starting with 0 raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid phone format"):
|
|
|
|
|
validate_phone("+0791234567")
|
|
|
|
|
|
|
|
|
|
def test_invalid_phone_has_letters(self):
|
|
|
|
|
"""Test that phone with letters raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid phone format"):
|
|
|
|
|
validate_phone("+41abc1234567")
|
|
|
|
|
|
|
|
|
|
def test_none_is_allowed(self):
|
|
|
|
|
"""Test that None passes validation (optional field)."""
|
|
|
|
|
validate_phone(None)
|
|
|
|
|
# No exception = pass
|
|
|
|
|
|
|
|
|
|
def test_empty_string_is_allowed(self):
|
|
|
|
|
"""Test that empty string passes validation (optional field)."""
|
|
|
|
|
validate_phone("")
|
|
|
|
|
# No exception = pass
|
2025-12-04 12:15:55 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMemberTypeRequirements:
|
|
|
|
|
"""Tests for validate_member_type_requirements function."""
|
|
|
|
|
|
|
|
|
|
def test_individual_with_first_name_valid(self):
|
|
|
|
|
"""Test INDIVIDUAL with firstName passes."""
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INDIVIDUAL",
|
|
|
|
|
first_name="John",
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_individual_without_first_name_invalid(self):
|
|
|
|
|
"""Test INDIVIDUAL without firstName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INDIVIDUAL",
|
|
|
|
|
first_name=None,
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_individual_with_empty_first_name_invalid(self):
|
|
|
|
|
"""Test INDIVIDUAL with empty firstName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INDIVIDUAL",
|
|
|
|
|
first_name="",
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_individual_with_whitespace_first_name_invalid(self):
|
|
|
|
|
"""Test INDIVIDUAL with whitespace-only firstName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="firstName is required for individual members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INDIVIDUAL",
|
|
|
|
|
first_name=" ",
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_organization_with_company_name_valid(self):
|
|
|
|
|
"""Test ORGANIZATION with companyName passes."""
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="ORGANIZATION",
|
|
|
|
|
first_name=None,
|
|
|
|
|
company_name="Acme Corp"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_organization_without_company_name_invalid(self):
|
|
|
|
|
"""Test ORGANIZATION without companyName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="ORGANIZATION",
|
|
|
|
|
first_name=None,
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_organization_with_empty_company_name_invalid(self):
|
|
|
|
|
"""Test ORGANIZATION with empty companyName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="ORGANIZATION",
|
|
|
|
|
first_name=None,
|
|
|
|
|
company_name=""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_organization_with_whitespace_company_name_invalid(self):
|
|
|
|
|
"""Test ORGANIZATION with whitespace-only companyName fails."""
|
|
|
|
|
with pytest.raises(ValidationError, match="companyName is required for organization members"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="ORGANIZATION",
|
|
|
|
|
first_name=None,
|
|
|
|
|
company_name=" "
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_invalid_member_type_raises_error(self):
|
|
|
|
|
"""Test invalid member type raises ValidationError."""
|
|
|
|
|
with pytest.raises(ValidationError, match="Invalid member type"):
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INVALID",
|
|
|
|
|
first_name="John",
|
|
|
|
|
company_name=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_organization_can_have_first_name_optional(self):
|
|
|
|
|
"""Test ORGANIZATION can optionally have firstName."""
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="ORGANIZATION",
|
|
|
|
|
first_name="John Doe",
|
|
|
|
|
company_name="Acme Corp"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_individual_can_have_company_name_optional(self):
|
|
|
|
|
"""Test INDIVIDUAL can optionally have companyName (edge case)."""
|
|
|
|
|
validate_member_type_requirements(
|
|
|
|
|
member_type="INDIVIDUAL",
|
|
|
|
|
first_name="John",
|
|
|
|
|
company_name="Acme Corp"
|
|
|
|
|
)
|