From f4320cffce60a09a70b506866edf72e2cf44b3c1 Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 09:29:08 +0000 Subject: [PATCH 1/9] fixes --- src/{userverse => generic_models}/__init__.py | 0 .../app_error.py | 0 .../generic_pagination.py | 0 .../generic_response.py | 0 .../company => userverse_models}/__init__.py | 0 .../company}/__init__.py | 0 .../company/address.py | 0 .../company/company.py | 10 +++++-- .../company/roles.py | 10 +++++-- .../company/user.py | 6 +++- .../user}/__init__.py | 0 .../user/password.py | 2 ++ .../user/user.py | 11 ++++++- src/userverse_models/validators/__init__.py | 0 .../validators/phone_number.py | 0 tests/readme.md | 5 ++++ tests/test_generic_models.py | 0 tests/test_userverse_models.py | 30 +++++++++++++++---- 18 files changed, 62 insertions(+), 12 deletions(-) rename src/{userverse => generic_models}/__init__.py (100%) rename src/{userverse => generic_models}/app_error.py (100%) rename src/{userverse => generic_models}/generic_pagination.py (100%) rename src/{userverse => generic_models}/generic_response.py (100%) rename src/{userverse/company => userverse_models}/__init__.py (100%) rename src/{userverse/user => userverse_models/company}/__init__.py (100%) rename src/{userverse => userverse_models}/company/address.py (100%) rename src/{userverse => userverse_models}/company/company.py (85%) rename src/{userverse => userverse_models}/company/roles.py (80%) rename src/{userverse => userverse_models}/company/user.py (67%) rename src/{userverse/validators => userverse_models/user}/__init__.py (100%) rename src/{userverse => userverse_models}/user/password.py (57%) rename src/{userverse => userverse_models}/user/user.py (87%) create mode 100644 src/userverse_models/validators/__init__.py rename src/{userverse => userverse_models}/validators/phone_number.py (100%) create mode 100644 tests/readme.md create mode 100644 tests/test_generic_models.py diff --git a/src/userverse/__init__.py b/src/generic_models/__init__.py similarity index 100% rename from src/userverse/__init__.py rename to src/generic_models/__init__.py diff --git a/src/userverse/app_error.py b/src/generic_models/app_error.py similarity index 100% rename from src/userverse/app_error.py rename to src/generic_models/app_error.py diff --git a/src/userverse/generic_pagination.py b/src/generic_models/generic_pagination.py similarity index 100% rename from src/userverse/generic_pagination.py rename to src/generic_models/generic_pagination.py diff --git a/src/userverse/generic_response.py b/src/generic_models/generic_response.py similarity index 100% rename from src/userverse/generic_response.py rename to src/generic_models/generic_response.py diff --git a/src/userverse/company/__init__.py b/src/userverse_models/__init__.py similarity index 100% rename from src/userverse/company/__init__.py rename to src/userverse_models/__init__.py diff --git a/src/userverse/user/__init__.py b/src/userverse_models/company/__init__.py similarity index 100% rename from src/userverse/user/__init__.py rename to src/userverse_models/company/__init__.py diff --git a/src/userverse/company/address.py b/src/userverse_models/company/address.py similarity index 100% rename from src/userverse/company/address.py rename to src/userverse_models/company/address.py diff --git a/src/userverse/company/company.py b/src/userverse_models/company/company.py similarity index 85% rename from src/userverse/company/company.py rename to src/userverse_models/company/company.py index 000b4a1..c5358a3 100644 --- a/src/userverse/company/company.py +++ b/src/userverse_models/company/company.py @@ -2,12 +2,13 @@ from pydantic import BaseModel, EmailStr, field_validator, Field +from src.generic_models.generic_pagination import PaginationParams + from .address import CompanyAddressModel from ..validators.phone_number import validate_phone_number_format -from ..generic_pagination import PaginationParams - class CompanyReadModel(BaseModel): + """Model representing a company.""" id: int name: Optional[str] = None description: Optional[str] = None @@ -20,6 +21,7 @@ class CompanyReadModel(BaseModel): class CompanyUpdateModel(BaseModel): + """Model for updating company details.""" name: Optional[str] = None description: Optional[str] = None industry: Optional[str] = None @@ -29,12 +31,14 @@ class CompanyUpdateModel(BaseModel): address: Optional[CompanyAddressModel] = None @field_validator("phone_number") + @classmethod def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: """Validate phone number format.""" return validate_phone_number_format(v) class CompanyCreateModel(BaseModel): + """Model for creating a new company.""" name: Optional[str] = None description: Optional[str] = None industry: Optional[str] = None @@ -45,12 +49,14 @@ class CompanyCreateModel(BaseModel): address: Optional[CompanyAddressModel] = None @field_validator("phone_number") + @classmethod def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: """Validate phone number format.""" return validate_phone_number_format(v) class CompanyQueryParamsModel(PaginationParams): + """Model for querying companies with optional filters.""" role_name: Optional[str] = None name: Optional[str] = None description: Optional[str] = None diff --git a/src/userverse/company/roles.py b/src/userverse_models/company/roles.py similarity index 80% rename from src/userverse/company/roles.py rename to src/userverse_models/company/roles.py index beb8995..9bc5ee0 100644 --- a/src/userverse/company/roles.py +++ b/src/userverse_models/company/roles.py @@ -3,10 +3,10 @@ from pydantic import BaseModel from pydantic import field_validator, Field -from ..generic_pagination import PaginationParams - +from src.generic_models.generic_pagination import PaginationParams class CompanyDefaultRoles(str, Enum): + """Enumeration of default company roles with descriptions.""" ADMINISTRATOR = "Administrator: Full access to manage users and data" VIEWER = "Viewer: Read-only access to company data" @@ -22,20 +22,24 @@ def description(self) -> str: class RoleCreateModel(BaseModel): + """Model for creating a new role.""" name: str description: Optional[str] class RoleUpdateModel(BaseModel): + """Model for updating a role.""" name: Optional[str] description: Optional[str] class RoleDeleteModel(BaseModel): + """Model for deleting a role with replacement.""" replacement_role_name: str role_name_to_delete: str @field_validator("role_name_to_delete") + @classmethod def validate_not_default_role(cls, v: str) -> str: """Ensure that default system roles cannot be deleted.""" default_roles = {r.name_value for r in CompanyDefaultRoles} @@ -45,10 +49,12 @@ def validate_not_default_role(cls, v: str) -> str: class RoleReadModel(BaseModel): + """Model representing a role.""" name: Optional[str] description: Optional[str] class RoleQueryParamsModel(PaginationParams): + """Model for querying roles with optional filters.""" name: Optional[str] = Field(None, description="Filter by role name") description: Optional[str] = Field(None, description="Filter by role description") diff --git a/src/userverse/company/user.py b/src/userverse_models/company/user.py similarity index 67% rename from src/userverse/company/user.py rename to src/userverse_models/company/user.py index 52cc1ee..4f95a06 100644 --- a/src/userverse/company/user.py +++ b/src/userverse_models/company/user.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel, EmailStr, Field from .roles import CompanyDefaultRoles @@ -5,11 +7,13 @@ class CompanyUserReadModel(UserReadModel): + """Model representing a user within a company, including their role.""" role_name: str class CompanyUserAddModel(BaseModel): - email: EmailStr = Field( + """Model for adding a user to a company with a specific role.""" + email: Optional[EmailStr] = Field( default=None, json_schema_extra={"example": "user.one@email.com"}, ) diff --git a/src/userverse/validators/__init__.py b/src/userverse_models/user/__init__.py similarity index 100% rename from src/userverse/validators/__init__.py rename to src/userverse_models/user/__init__.py diff --git a/src/userverse/user/password.py b/src/userverse_models/user/password.py similarity index 57% rename from src/userverse/user/password.py rename to src/userverse_models/user/password.py index a57c671..c5ec07a 100644 --- a/src/userverse/user/password.py +++ b/src/userverse_models/user/password.py @@ -2,8 +2,10 @@ class PasswordResetRequest(BaseModel): + """Model for requesting a password reset via email.""" email: EmailStr class OTPValidationRequest(BaseModel): + """Model for validating OTP during password reset.""" otp: str diff --git a/src/userverse/user/user.py b/src/userverse_models/user/user.py similarity index 87% rename from src/userverse/user/user.py rename to src/userverse_models/user/user.py index d8ad629..6b94ded 100644 --- a/src/userverse/user/user.py +++ b/src/userverse_models/user/user.py @@ -2,16 +2,19 @@ from pydantic import BaseModel, EmailStr, field_validator, Field +from src.generic_models.generic_pagination import PaginationParams + from ..validators.phone_number import validate_phone_number_format -from ..generic_pagination import PaginationParams class UserLoginModel(BaseModel): + """Model for user login.""" email: EmailStr password: str class UserUpdateModel(BaseModel): + """Model for updating user details.""" first_name: Optional[str] = None last_name: Optional[str] = None phone_number: Optional[str] = Field( @@ -20,12 +23,14 @@ class UserUpdateModel(BaseModel): password: Optional[str] = None @field_validator("phone_number") + @classmethod def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: """Validate phone number format.""" return validate_phone_number_format(v) class UserCreateModel(BaseModel): + """Model for creating a new user.""" first_name: Optional[str] = None last_name: Optional[str] = None phone_number: Optional[str] = Field( @@ -33,12 +38,14 @@ class UserCreateModel(BaseModel): ) @field_validator("phone_number") + @classmethod def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: """Validate phone number format.""" return validate_phone_number_format(v) class UserReadModel(BaseModel): + """Model representing a user.""" id: int first_name: Optional[str] = None last_name: Optional[str] = None @@ -53,6 +60,7 @@ class UserReadModel(BaseModel): class TokenResponseModel(BaseModel): + """Model for token response.""" token_type: Literal["bearer"] = Field( "bearer", description="Type of the token", @@ -68,6 +76,7 @@ class TokenResponseModel(BaseModel): class UserQueryParams(PaginationParams): + """Model for querying users with optional filters.""" role_name: Optional[str] = Field(None, description="Filter by role name") first_name: Optional[str] = Field(None, description="Filter by user first name") last_name: Optional[str] = Field(None, description="Filter by user last name") diff --git a/src/userverse_models/validators/__init__.py b/src/userverse_models/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/userverse/validators/phone_number.py b/src/userverse_models/validators/phone_number.py similarity index 100% rename from src/userverse/validators/phone_number.py rename to src/userverse_models/validators/phone_number.py diff --git a/tests/readme.md b/tests/readme.md new file mode 100644 index 0000000..2f82916 --- /dev/null +++ b/tests/readme.md @@ -0,0 +1,5 @@ +# how to run the package after git clone +# how the to use the package in separate projects, pyndatic version +# running and discussing test + +# TODO: adding shared fast-api utils \ No newline at end of file diff --git a/tests/test_generic_models.py b/tests/test_generic_models.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_userverse_models.py b/tests/test_userverse_models.py index cef47ff..c2ab5ad 100644 --- a/tests/test_userverse_models.py +++ b/tests/test_userverse_models.py @@ -1,51 +1,62 @@ import pytest from pydantic import ValidationError -from src.userverse.generic_pagination import PaginationParams -from src.userverse.validators.phone_number import validate_phone_number_format -from src.userverse.user.user import ( +from src.generic_models.generic_pagination import PaginationParams +from src.userverse_models.validators.phone_number import validate_phone_number_format +from src.userverse_models.user.user import ( TokenResponseModel, UserCreateModel, UserQueryParams, ) -from src.userverse.company.roles import CompanyDefaultRoles, RoleDeleteModel -from src.userverse.company.user import CompanyUserAddModel -from src.userverse.company.company import CompanyCreateModel +from src.userverse_models.company.roles import CompanyDefaultRoles, RoleDeleteModel +from src.userverse_models.company.user import CompanyUserAddModel +from src.userverse_models.company.company import CompanyCreateModel class TestPhoneNumberValidation: + """Tests for phone number validation.""" def test_accepts_none(self): + """Test that None is accepted and returns None.""" assert validate_phone_number_format(None) is None def test_valid_number_returns_e164(self): + """Test that a valid phone number is returned in E.164 format.""" assert validate_phone_number_format("+12025550123") == "+12025550123" def test_invalid_number_raises_value_error(self): + """Test that an invalid phone number raises a ValueError.""" with pytest.raises(ValueError): validate_phone_number_format("123") class TestPaginationParams: + """Tests for PaginationParams model.""" def test_offset_calculation_defaults(self): + """Test that default pagination parameters calculate offset correctly.""" params = PaginationParams() assert params.offset == 0 def test_offset_calculation_non_default(self): + """Test that non-default pagination parameters calculate offset correctly.""" params = PaginationParams(limit=25, page=3) assert params.offset == 50 class TestUserModels: + """Tests for user models.""" def test_user_create_validates_and_formats_phone_number(self): + """Test that UserCreateModel validates and formats phone number.""" user = UserCreateModel(phone_number="+441234567890") assert user.phone_number == "+441234567890" def test_user_query_params_inherit_pagination(self): + """Test that UserQueryParams inherits from PaginationParams.""" params = UserQueryParams(page=2) assert params.limit == 10 assert params.offset == 10 def test_token_response_default_token_type(self): + """Test that TokenResponseModel has default token_type 'bearer'.""" token = TokenResponseModel( access_token="access", access_token_expiration="2025-01-01 00:00:00", @@ -56,7 +67,9 @@ def test_token_response_default_token_type(self): class TestCompanyModels: + """Tests for company models.""" def test_company_create_validates_phone_number(self): + """Test that CompanyCreateModel validates phone number.""" company = CompanyCreateModel( phone_number="+12025550123", email="info@example.com", @@ -65,7 +78,9 @@ def test_company_create_validates_phone_number(self): class TestCompanyRoles: + """Tests for company role models.""" def test_default_role_properties(self): + """Test that CompanyDefaultRoles properties return correct values.""" assert CompanyDefaultRoles.ADMINISTRATOR.name_value == "Administrator" assert ( CompanyDefaultRoles.ADMINISTRATOR.description @@ -73,6 +88,7 @@ def test_default_role_properties(self): ) def test_role_delete_rejects_default_role(self): + """Test that RoleDeleteModel raises ValidationError when deleting a default role.""" with pytest.raises(ValidationError): RoleDeleteModel( replacement_role_name="Manager", @@ -81,6 +97,8 @@ def test_role_delete_rejects_default_role(self): class TestCompanyUser: + """Tests for company user models.""" def test_company_user_add_default_role(self): + """Test that CompanyUserAddModel assigns default role if none provided.""" company_user = CompanyUserAddModel(email="user@example.com") assert company_user.role == CompanyDefaultRoles.VIEWER.name_value From 485eb65d6a217846faa4eb1c728d928619822232 Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 09:45:30 +0000 Subject: [PATCH 2/9] comments for classes and functions --- src/generic_models/app_error.py | 4 ++-- src/generic_models/generic_pagination.py | 21 ++++++++++--------- src/generic_models/generic_response.py | 2 +- src/userverse_models/company/address.py | 1 + .../validators/phone_number.py | 1 + 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/generic_models/app_error.py b/src/generic_models/app_error.py index 3db5a20..90e4860 100644 --- a/src/generic_models/app_error.py +++ b/src/generic_models/app_error.py @@ -1,10 +1,10 @@ from pydantic import BaseModel - class DetailModel(BaseModel): + """Model representing error details.""" message: str error: str - class AppErrorResponseModel(BaseModel): + """Model representing an application error response.""" detail: DetailModel diff --git a/src/generic_models/generic_pagination.py b/src/generic_models/generic_pagination.py index 0714e00..905fe94 100644 --- a/src/generic_models/generic_pagination.py +++ b/src/generic_models/generic_pagination.py @@ -3,31 +3,31 @@ from typing import Generic, List, TypeVar from pydantic import BaseModel, Field - T = TypeVar("T") - class MatchType(str, Enum): + """Enumeration for different types of string matching.""" PARTIAL = "partial" EXACT = "exact" STARTS_WITH = "starts_with" - - + class FilterLogic(str, Enum): + """Enumeration for filter logic operators.""" OR = "or" AND = "and" - - + class PaginationParams(BaseModel): + """Model for pagination parameters.""" limit: int = Field(10, ge=1, le=100) page: int = Field(1, ge=1) # Page is 1-indexed - @property - def offset(self) -> int: - return (self.page - 1) * self.limit - + @classmethod + def offset(cls) -> int: + """Calculate the offset based on the current page and limit.""" + return (cls.page - 1) * cls.limit class PaginationMeta(BaseModel): + """Model for pagination metadata.""" total_records: int limit: int current_page: int @@ -35,5 +35,6 @@ class PaginationMeta(BaseModel): class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated response model.""" records: List[T] pagination: PaginationMeta diff --git a/src/generic_models/generic_response.py b/src/generic_models/generic_response.py index 45b1d5b..eb8fabd 100644 --- a/src/generic_models/generic_response.py +++ b/src/generic_models/generic_response.py @@ -3,7 +3,7 @@ T = TypeVar("T") - class GenericResponseModel(BaseModel, Generic[T]): + """Generic response model wrapping a message and optional data.""" message: str data: Optional[T] diff --git a/src/userverse_models/company/address.py b/src/userverse_models/company/address.py index 182998d..c9d6510 100644 --- a/src/userverse_models/company/address.py +++ b/src/userverse_models/company/address.py @@ -3,6 +3,7 @@ class CompanyAddressModel(BaseModel): + """Model representing a company's address.""" street: Optional[str] = Field(None, json_schema_extra={"example": "123 Main St"}) city: Optional[str] = Field(None, json_schema_extra={"example": "Cape Town"}) state: Optional[str] = Field(None, json_schema_extra={"example": "CT"}) diff --git a/src/userverse_models/validators/phone_number.py b/src/userverse_models/validators/phone_number.py index 5b5d8d4..5da98ae 100644 --- a/src/userverse_models/validators/phone_number.py +++ b/src/userverse_models/validators/phone_number.py @@ -3,6 +3,7 @@ def validate_phone_number_format(phone: Optional[str]) -> Optional[str]: + """Validate and format the phone number to E.164 standard.""" if not phone: return phone From f80978a4847ca566ffebdb0afd66e75e8fb75b4d Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 10:54:48 +0000 Subject: [PATCH 3/9] package cleanup --- src/{userverse_models => }/validators/__init__.py | 0 src/{userverse_models => }/validators/phone_number.py | 0 tests/{readme.md => ReadMe.md} | 0 .../{test_generic_models.py => generic_models/__init__.py} | 0 tests/generic_models/test_generic_models.py | 0 tests/userverse_models/__init__.py | 0 tests/userverse_models/test_company.py | 0 tests/userverse_models/test_user.py | 0 tests/{ => userverse_models}/test_userverse_models.py | 6 +++--- 9 files changed, 3 insertions(+), 3 deletions(-) rename src/{userverse_models => }/validators/__init__.py (100%) rename src/{userverse_models => }/validators/phone_number.py (100%) rename tests/{readme.md => ReadMe.md} (100%) rename tests/{test_generic_models.py => generic_models/__init__.py} (100%) create mode 100644 tests/generic_models/test_generic_models.py create mode 100644 tests/userverse_models/__init__.py create mode 100644 tests/userverse_models/test_company.py create mode 100644 tests/userverse_models/test_user.py rename tests/{ => userverse_models}/test_userverse_models.py (97%) diff --git a/src/userverse_models/validators/__init__.py b/src/validators/__init__.py similarity index 100% rename from src/userverse_models/validators/__init__.py rename to src/validators/__init__.py diff --git a/src/userverse_models/validators/phone_number.py b/src/validators/phone_number.py similarity index 100% rename from src/userverse_models/validators/phone_number.py rename to src/validators/phone_number.py diff --git a/tests/readme.md b/tests/ReadMe.md similarity index 100% rename from tests/readme.md rename to tests/ReadMe.md diff --git a/tests/test_generic_models.py b/tests/generic_models/__init__.py similarity index 100% rename from tests/test_generic_models.py rename to tests/generic_models/__init__.py diff --git a/tests/generic_models/test_generic_models.py b/tests/generic_models/test_generic_models.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/userverse_models/__init__.py b/tests/userverse_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/userverse_models/test_company.py b/tests/userverse_models/test_company.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/userverse_models/test_user.py b/tests/userverse_models/test_user.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_userverse_models.py b/tests/userverse_models/test_userverse_models.py similarity index 97% rename from tests/test_userverse_models.py rename to tests/userverse_models/test_userverse_models.py index c2ab5ad..3dda2f1 100644 --- a/tests/test_userverse_models.py +++ b/tests/userverse_models/test_userverse_models.py @@ -34,12 +34,12 @@ class TestPaginationParams: def test_offset_calculation_defaults(self): """Test that default pagination parameters calculate offset correctly.""" params = PaginationParams() - assert params.offset == 0 + assert params.offset() == 0 def test_offset_calculation_non_default(self): """Test that non-default pagination parameters calculate offset correctly.""" params = PaginationParams(limit=25, page=3) - assert params.offset == 50 + assert params.offset() == 50 class TestUserModels: @@ -53,7 +53,7 @@ def test_user_query_params_inherit_pagination(self): """Test that UserQueryParams inherits from PaginationParams.""" params = UserQueryParams(page=2) assert params.limit == 10 - assert params.offset == 10 + assert params.offset() == 10 def test_token_response_default_token_type(self): """Test that TokenResponseModel has default token_type 'bearer'.""" From a0bf2d49472a983576501edd811d520507e0a497 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 4 Jan 2026 11:19:21 +0000 Subject: [PATCH 4/9] style: auto-format code with Black --- src/generic_models/app_error.py | 4 ++++ src/generic_models/generic_pagination.py | 13 +++++++++++-- src/generic_models/generic_response.py | 2 ++ src/userverse_models/company/address.py | 1 + src/userverse_models/company/company.py | 7 ++++++- src/userverse_models/company/roles.py | 7 +++++++ src/userverse_models/company/user.py | 2 ++ src/userverse_models/user/password.py | 2 ++ src/userverse_models/user/user.py | 6 ++++++ tests/userverse_models/test_userverse_models.py | 8 +++++++- 10 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/generic_models/app_error.py b/src/generic_models/app_error.py index 90e4860..5ef0a8c 100644 --- a/src/generic_models/app_error.py +++ b/src/generic_models/app_error.py @@ -1,10 +1,14 @@ from pydantic import BaseModel + class DetailModel(BaseModel): """Model representing error details.""" + message: str error: str + class AppErrorResponseModel(BaseModel): """Model representing an application error response.""" + detail: DetailModel diff --git a/src/generic_models/generic_pagination.py b/src/generic_models/generic_pagination.py index 905fe94..cefb695 100644 --- a/src/generic_models/generic_pagination.py +++ b/src/generic_models/generic_pagination.py @@ -5,19 +5,25 @@ T = TypeVar("T") + class MatchType(str, Enum): """Enumeration for different types of string matching.""" + PARTIAL = "partial" EXACT = "exact" STARTS_WITH = "starts_with" - + + class FilterLogic(str, Enum): """Enumeration for filter logic operators.""" + OR = "or" AND = "and" - + + class PaginationParams(BaseModel): """Model for pagination parameters.""" + limit: int = Field(10, ge=1, le=100) page: int = Field(1, ge=1) # Page is 1-indexed @@ -26,8 +32,10 @@ def offset(cls) -> int: """Calculate the offset based on the current page and limit.""" return (cls.page - 1) * cls.limit + class PaginationMeta(BaseModel): """Model for pagination metadata.""" + total_records: int limit: int current_page: int @@ -36,5 +44,6 @@ class PaginationMeta(BaseModel): class PaginatedResponse(BaseModel, Generic[T]): """Generic paginated response model.""" + records: List[T] pagination: PaginationMeta diff --git a/src/generic_models/generic_response.py b/src/generic_models/generic_response.py index eb8fabd..ace41ec 100644 --- a/src/generic_models/generic_response.py +++ b/src/generic_models/generic_response.py @@ -3,7 +3,9 @@ T = TypeVar("T") + class GenericResponseModel(BaseModel, Generic[T]): """Generic response model wrapping a message and optional data.""" + message: str data: Optional[T] diff --git a/src/userverse_models/company/address.py b/src/userverse_models/company/address.py index c9d6510..f1a9394 100644 --- a/src/userverse_models/company/address.py +++ b/src/userverse_models/company/address.py @@ -4,6 +4,7 @@ class CompanyAddressModel(BaseModel): """Model representing a company's address.""" + street: Optional[str] = Field(None, json_schema_extra={"example": "123 Main St"}) city: Optional[str] = Field(None, json_schema_extra={"example": "Cape Town"}) state: Optional[str] = Field(None, json_schema_extra={"example": "CT"}) diff --git a/src/userverse_models/company/company.py b/src/userverse_models/company/company.py index c5358a3..2d294e3 100644 --- a/src/userverse_models/company/company.py +++ b/src/userverse_models/company/company.py @@ -7,8 +7,10 @@ from .address import CompanyAddressModel from ..validators.phone_number import validate_phone_number_format + class CompanyReadModel(BaseModel): """Model representing a company.""" + id: int name: Optional[str] = None description: Optional[str] = None @@ -22,6 +24,7 @@ class CompanyReadModel(BaseModel): class CompanyUpdateModel(BaseModel): """Model for updating company details.""" + name: Optional[str] = None description: Optional[str] = None industry: Optional[str] = None @@ -39,6 +42,7 @@ def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: class CompanyCreateModel(BaseModel): """Model for creating a new company.""" + name: Optional[str] = None description: Optional[str] = None industry: Optional[str] = None @@ -56,7 +60,8 @@ def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: class CompanyQueryParamsModel(PaginationParams): - """Model for querying companies with optional filters.""" + """Model for querying companies with optional filters.""" + role_name: Optional[str] = None name: Optional[str] = None description: Optional[str] = None diff --git a/src/userverse_models/company/roles.py b/src/userverse_models/company/roles.py index 9bc5ee0..cc8ef8b 100644 --- a/src/userverse_models/company/roles.py +++ b/src/userverse_models/company/roles.py @@ -5,8 +5,10 @@ from src.generic_models.generic_pagination import PaginationParams + class CompanyDefaultRoles(str, Enum): """Enumeration of default company roles with descriptions.""" + ADMINISTRATOR = "Administrator: Full access to manage users and data" VIEWER = "Viewer: Read-only access to company data" @@ -23,18 +25,21 @@ def description(self) -> str: class RoleCreateModel(BaseModel): """Model for creating a new role.""" + name: str description: Optional[str] class RoleUpdateModel(BaseModel): """Model for updating a role.""" + name: Optional[str] description: Optional[str] class RoleDeleteModel(BaseModel): """Model for deleting a role with replacement.""" + replacement_role_name: str role_name_to_delete: str @@ -50,11 +55,13 @@ def validate_not_default_role(cls, v: str) -> str: class RoleReadModel(BaseModel): """Model representing a role.""" + name: Optional[str] description: Optional[str] class RoleQueryParamsModel(PaginationParams): """Model for querying roles with optional filters.""" + name: Optional[str] = Field(None, description="Filter by role name") description: Optional[str] = Field(None, description="Filter by role description") diff --git a/src/userverse_models/company/user.py b/src/userverse_models/company/user.py index 4f95a06..f410458 100644 --- a/src/userverse_models/company/user.py +++ b/src/userverse_models/company/user.py @@ -8,11 +8,13 @@ class CompanyUserReadModel(UserReadModel): """Model representing a user within a company, including their role.""" + role_name: str class CompanyUserAddModel(BaseModel): """Model for adding a user to a company with a specific role.""" + email: Optional[EmailStr] = Field( default=None, json_schema_extra={"example": "user.one@email.com"}, diff --git a/src/userverse_models/user/password.py b/src/userverse_models/user/password.py index c5ec07a..966aef0 100644 --- a/src/userverse_models/user/password.py +++ b/src/userverse_models/user/password.py @@ -3,9 +3,11 @@ class PasswordResetRequest(BaseModel): """Model for requesting a password reset via email.""" + email: EmailStr class OTPValidationRequest(BaseModel): """Model for validating OTP during password reset.""" + otp: str diff --git a/src/userverse_models/user/user.py b/src/userverse_models/user/user.py index 6b94ded..f009f5a 100644 --- a/src/userverse_models/user/user.py +++ b/src/userverse_models/user/user.py @@ -9,12 +9,14 @@ class UserLoginModel(BaseModel): """Model for user login.""" + email: EmailStr password: str class UserUpdateModel(BaseModel): """Model for updating user details.""" + first_name: Optional[str] = None last_name: Optional[str] = None phone_number: Optional[str] = Field( @@ -31,6 +33,7 @@ def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: class UserCreateModel(BaseModel): """Model for creating a new user.""" + first_name: Optional[str] = None last_name: Optional[str] = None phone_number: Optional[str] = Field( @@ -46,6 +49,7 @@ def validate_phone_number(cls, v: Optional[str]) -> Optional[str]: class UserReadModel(BaseModel): """Model representing a user.""" + id: int first_name: Optional[str] = None last_name: Optional[str] = None @@ -61,6 +65,7 @@ class UserReadModel(BaseModel): class TokenResponseModel(BaseModel): """Model for token response.""" + token_type: Literal["bearer"] = Field( "bearer", description="Type of the token", @@ -77,6 +82,7 @@ class TokenResponseModel(BaseModel): class UserQueryParams(PaginationParams): """Model for querying users with optional filters.""" + role_name: Optional[str] = Field(None, description="Filter by role name") first_name: Optional[str] = Field(None, description="Filter by user first name") last_name: Optional[str] = Field(None, description="Filter by user last name") diff --git a/tests/userverse_models/test_userverse_models.py b/tests/userverse_models/test_userverse_models.py index 3dda2f1..12a24ed 100644 --- a/tests/userverse_models/test_userverse_models.py +++ b/tests/userverse_models/test_userverse_models.py @@ -15,6 +15,7 @@ class TestPhoneNumberValidation: """Tests for phone number validation.""" + def test_accepts_none(self): """Test that None is accepted and returns None.""" assert validate_phone_number_format(None) is None @@ -30,7 +31,8 @@ def test_invalid_number_raises_value_error(self): class TestPaginationParams: - """Tests for PaginationParams model.""" + """Tests for PaginationParams model.""" + def test_offset_calculation_defaults(self): """Test that default pagination parameters calculate offset correctly.""" params = PaginationParams() @@ -44,6 +46,7 @@ def test_offset_calculation_non_default(self): class TestUserModels: """Tests for user models.""" + def test_user_create_validates_and_formats_phone_number(self): """Test that UserCreateModel validates and formats phone number.""" user = UserCreateModel(phone_number="+441234567890") @@ -68,6 +71,7 @@ def test_token_response_default_token_type(self): class TestCompanyModels: """Tests for company models.""" + def test_company_create_validates_phone_number(self): """Test that CompanyCreateModel validates phone number.""" company = CompanyCreateModel( @@ -79,6 +83,7 @@ def test_company_create_validates_phone_number(self): class TestCompanyRoles: """Tests for company role models.""" + def test_default_role_properties(self): """Test that CompanyDefaultRoles properties return correct values.""" assert CompanyDefaultRoles.ADMINISTRATOR.name_value == "Administrator" @@ -98,6 +103,7 @@ def test_role_delete_rejects_default_role(self): class TestCompanyUser: """Tests for company user models.""" + def test_company_user_add_default_role(self): """Test that CompanyUserAddModel assigns default role if none provided.""" company_user = CompanyUserAddModel(email="user@example.com") From db8802f73701e9d0ea4e82961a3b49ef23569bba Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 11:01:11 +0000 Subject: [PATCH 5/9] updated generic models --- src/generic_models/generic_pagination.py | 5 +- tests/generic_models/ReadMe.md | 13 +++ tests/generic_models/test_generic_models.py | 111 ++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/generic_models/ReadMe.md diff --git a/src/generic_models/generic_pagination.py b/src/generic_models/generic_pagination.py index cefb695..6a67aea 100644 --- a/src/generic_models/generic_pagination.py +++ b/src/generic_models/generic_pagination.py @@ -27,10 +27,9 @@ class PaginationParams(BaseModel): limit: int = Field(10, ge=1, le=100) page: int = Field(1, ge=1) # Page is 1-indexed - @classmethod - def offset(cls) -> int: + def offset(self) -> int: """Calculate the offset based on the current page and limit.""" - return (cls.page - 1) * cls.limit + return (self.page - 1) * self.limit class PaginationMeta(BaseModel): diff --git a/tests/generic_models/ReadMe.md b/tests/generic_models/ReadMe.md new file mode 100644 index 0000000..759b06f --- /dev/null +++ b/tests/generic_models/ReadMe.md @@ -0,0 +1,13 @@ +# Generic Models Tests + +These tests cover the shared Pydantic models in `src/generic_models`: +- `app_error.py`: `DetailModel` and `AppErrorResponseModel` +- `generic_response.py`: `GenericResponseModel` +- `generic_pagination.py`: enums, pagination params, and paginated response helpers + +## How to run +From the repository root: + +```bash +pytest tests/generic_models +``` diff --git a/tests/generic_models/test_generic_models.py b/tests/generic_models/test_generic_models.py index e69de29..135aa4e 100644 --- a/tests/generic_models/test_generic_models.py +++ b/tests/generic_models/test_generic_models.py @@ -0,0 +1,111 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from src.generic_models.app_error import AppErrorResponseModel, DetailModel +from src.generic_models.generic_pagination import ( + FilterLogic, + MatchType, + PaginatedResponse, + PaginationMeta, + PaginationParams, +) +from src.generic_models.generic_response import GenericResponseModel + + +class Item(BaseModel): + """Simple record type for pagination tests.""" + id: int + + +class TestAppErrorModels: + """Tests for application error models.""" + def test_detail_model_roundtrip(self): + """DetailModel should preserve message and error fields.""" + detail = DetailModel(message="Not found", error="missing_resource") + response = AppErrorResponseModel(detail=detail) + assert response.detail.message == "Not found" + assert response.detail.error == "missing_resource" + + def test_missing_detail_raises_validation_error(self): + """AppErrorResponseModel should require detail.""" + with pytest.raises(ValidationError): + AppErrorResponseModel() + + +class TestGenericResponseModel: + """Tests for GenericResponseModel.""" + def test_generic_response_with_data(self): + """GenericResponseModel should accept typed data.""" + response = GenericResponseModel[str](message="ok", data="value") + assert response.message == "ok" + assert response.data == "value" + + def test_generic_response_allows_none_data(self): + """GenericResponseModel should allow data to be None.""" + response = GenericResponseModel[Item](message="ok", data=None) + assert response.data is None + + def test_generic_response_requires_data_field(self): + """GenericResponseModel should require the data field even if optional.""" + with pytest.raises(ValidationError): + GenericResponseModel[str](message="ok") + + +class TestPaginationEnums: + """Tests for pagination enums.""" + def test_match_type_values(self): + """MatchType should expose expected string values.""" + assert MatchType.PARTIAL.value == "partial" + assert MatchType.EXACT.value == "exact" + assert MatchType.STARTS_WITH.value == "starts_with" + + def test_filter_logic_values(self): + """FilterLogic should expose expected string values.""" + assert FilterLogic.OR.value == "or" + assert FilterLogic.AND.value == "and" + + +class TestPaginationParams: + """Tests for PaginationParams.""" + def test_defaults(self): + """Default pagination values should be limit 10, page 1.""" + params = PaginationParams() + assert params.limit == 10 + assert params.page == 1 + assert params.offset() == 0 + + def test_offset_calculation(self): + """Offset should match (page - 1) * limit.""" + params = PaginationParams(limit=25, page=3) + assert params.offset() == 50 + + @pytest.mark.parametrize("limit", [0, 101]) + def test_limit_out_of_bounds_raises(self, limit): + """Limit outside [1, 100] should raise ValidationError.""" + with pytest.raises(ValidationError): + PaginationParams(limit=limit) + + @pytest.mark.parametrize("page", [0, -1]) + def test_page_out_of_bounds_raises(self, page): + """Page less than 1 should raise ValidationError.""" + with pytest.raises(ValidationError): + PaginationParams(page=page) + + +class TestPaginatedResponse: + """Tests for PaginatedResponse and PaginationMeta.""" + def test_paginated_response_structure(self): + """PaginatedResponse should accept records and pagination metadata.""" + meta = PaginationMeta( + total_records=2, + limit=10, + current_page=1, + total_pages=1, + ) + response = PaginatedResponse[Item]( + records=[Item(id=1), Item(id=2)], + pagination=meta, + ) + assert len(response.records) == 2 + assert response.records[0].id == 1 + assert response.pagination.total_records == 2 From 4f4308bb49832b016443dab1f9376026aa210c87 Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 11:33:50 +0000 Subject: [PATCH 6/9] test suite fix --- src/userverse_models/validators/__init__.py | 1 + .../validators/phone_number.py | 5 + tests/userverse_models/ReadMe.md | 23 ++++ tests/userverse_models/test_company.py | 75 ++++++++++++ tests/userverse_models/test_roles.py | 58 +++++++++ tests/userverse_models/test_user.py | 80 +++++++++++++ .../userverse_models/test_userverse_models.py | 110 ------------------ 7 files changed, 242 insertions(+), 110 deletions(-) create mode 100644 src/userverse_models/validators/__init__.py create mode 100644 src/userverse_models/validators/phone_number.py create mode 100644 tests/userverse_models/ReadMe.md create mode 100644 tests/userverse_models/test_roles.py delete mode 100644 tests/userverse_models/test_userverse_models.py diff --git a/src/userverse_models/validators/__init__.py b/src/userverse_models/validators/__init__.py new file mode 100644 index 0000000..0286af9 --- /dev/null +++ b/src/userverse_models/validators/__init__.py @@ -0,0 +1 @@ +"""Validation helpers for userverse models.""" diff --git a/src/userverse_models/validators/phone_number.py b/src/userverse_models/validators/phone_number.py new file mode 100644 index 0000000..340ed24 --- /dev/null +++ b/src/userverse_models/validators/phone_number.py @@ -0,0 +1,5 @@ +"""Compatibility shim for userverse model phone validation.""" + +from src.validators.phone_number import validate_phone_number_format + +__all__ = ["validate_phone_number_format"] diff --git a/tests/userverse_models/ReadMe.md b/tests/userverse_models/ReadMe.md new file mode 100644 index 0000000..b71864b --- /dev/null +++ b/tests/userverse_models/ReadMe.md @@ -0,0 +1,23 @@ +# Userverse Models Tests + +These tests cover the shared Pydantic models in `src/userverse_models`: +- `user/user.py`: login, create/update/read, token response, and query params +- `user/password.py`: password reset and OTP models +- `company/address.py`: address model +- `company/company.py`: company create/update/read and query params +- `company/user.py`: company user add/read models +- `company/roles.py`: role enums and role models + +## How to run +From the repository root: + +```bash +pytest tests/userverse_models +``` + +From the module folder: + +```bash +cd tests/userverse_models +pytest . +``` diff --git a/tests/userverse_models/test_company.py b/tests/userverse_models/test_company.py index e69de29..d091a16 100644 --- a/tests/userverse_models/test_company.py +++ b/tests/userverse_models/test_company.py @@ -0,0 +1,75 @@ +import pytest +from pydantic import ValidationError + +from src.userverse_models.company.address import CompanyAddressModel +from src.userverse_models.company.company import ( + CompanyCreateModel, + CompanyQueryParamsModel, + CompanyReadModel, + CompanyUpdateModel, +) +from src.userverse_models.company.user import CompanyUserAddModel, CompanyUserReadModel + + +class TestCompanyAddressModel: + """Tests for company address model.""" + def test_optional_fields_default_to_none(self): + """Address fields should default to None when not provided.""" + address = CompanyAddressModel() + assert address.street is None + assert address.city is None + assert address.state is None + assert address.postal_code is None + assert address.country is None + + def test_address_accepts_values(self): + """Address fields should accept provided values.""" + address = CompanyAddressModel( + street="123 Main St", + city="Cape Town", + state="CT", + postal_code="8000", + country="South Africa", + ) + assert address.street == "123 Main St" + assert address.city == "Cape Town" + assert address.state == "CT" + assert address.postal_code == "8000" + assert address.country == "South Africa" + + +class TestCompanyModels: + """Tests for company models.""" + def test_company_create_formats_phone_number(self): + """Phone number should be formatted to E.164.""" + company = CompanyCreateModel(phone_number="+12025550123", email="info@example.com") + assert company.phone_number == "+12025550123" + + def test_company_update_rejects_invalid_phone(self): + """Invalid phone numbers should raise ValidationError.""" + with pytest.raises(ValidationError): + CompanyUpdateModel(phone_number="123") + + def test_company_read_requires_id_and_email(self): + """CompanyReadModel should require id and email.""" + with pytest.raises(ValidationError): + CompanyReadModel() + + def test_company_query_params_defaults(self): + """Query params should inherit pagination defaults.""" + params = CompanyQueryParamsModel(page=3) + assert params.limit == 10 + assert params.offset() == 20 + + +class TestCompanyUserModels: + """Tests for company user models.""" + def test_company_user_add_defaults_role(self): + """Role should default to Viewer when not provided.""" + user = CompanyUserAddModel(email="user@example.com") + assert user.role == "Viewer" + + def test_company_user_read_requires_role(self): + """CompanyUserReadModel should require role_name.""" + with pytest.raises(ValidationError): + CompanyUserReadModel(id=1, email="user@example.com") diff --git a/tests/userverse_models/test_roles.py b/tests/userverse_models/test_roles.py new file mode 100644 index 0000000..28f4809 --- /dev/null +++ b/tests/userverse_models/test_roles.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import ValidationError + +from src.userverse_models.company.roles import ( + CompanyDefaultRoles, + RoleCreateModel, + RoleDeleteModel, + RoleQueryParamsModel, + RoleReadModel, + RoleUpdateModel, +) + + +class TestCompanyDefaultRoles: + """Tests for default roles enum.""" + def test_name_and_description_properties(self): + """Enum properties should split name and description.""" + assert CompanyDefaultRoles.ADMINISTRATOR.name_value == "Administrator" + assert ( + CompanyDefaultRoles.ADMINISTRATOR.description + == "Full access to manage users and data" + ) + + +class TestRoleModels: + """Tests for role models.""" + def test_role_create_roundtrip(self): + """RoleCreateModel should preserve provided values.""" + role = RoleCreateModel(name="Manager", description="Manages users") + assert role.name == "Manager" + assert role.description == "Manages users" + + def test_role_update_allows_partial(self): + """RoleUpdateModel should allow optional fields.""" + role = RoleUpdateModel(name=None, description="Updated") + assert role.name is None + assert role.description == "Updated" + + def test_role_read_allows_optional_fields(self): + """RoleReadModel should accept optional fields.""" + role = RoleReadModel(name=None, description=None) + assert role.name is None + assert role.description is None + + def test_role_delete_rejects_default_role(self): + """Deleting a default role should raise ValidationError.""" + with pytest.raises(ValidationError): + RoleDeleteModel( + replacement_role_name="Manager", + role_name_to_delete=CompanyDefaultRoles.ADMINISTRATOR.name_value, + ) + + def test_role_query_params_inherit_pagination(self): + """Query params should inherit pagination defaults.""" + params = RoleQueryParamsModel(page=2, name="Admin") + assert params.limit == 10 + assert params.offset() == 10 + assert params.name == "Admin" diff --git a/tests/userverse_models/test_user.py b/tests/userverse_models/test_user.py index e69de29..bdaa1e8 100644 --- a/tests/userverse_models/test_user.py +++ b/tests/userverse_models/test_user.py @@ -0,0 +1,80 @@ +import pytest +from pydantic import ValidationError + +from src.userverse_models.user.password import OTPValidationRequest, PasswordResetRequest +from src.userverse_models.user.user import ( + TokenResponseModel, + UserCreateModel, + UserLoginModel, + UserQueryParams, + UserReadModel, + UserUpdateModel, +) + + +class TestUserLoginModel: + """Tests for user login model.""" + def test_login_requires_valid_email(self): + """Invalid email should raise a ValidationError.""" + with pytest.raises(ValidationError): + UserLoginModel(email="not-an-email", password="secret") + + +class TestUserCreateModel: + """Tests for user creation model.""" + def test_user_create_formats_phone_number(self): + """Phone numbers should be formatted to E.164.""" + user = UserCreateModel(phone_number="+12025550123") + assert user.phone_number == "+12025550123" + + +class TestUserUpdateModel: + """Tests for user update model.""" + def test_user_update_rejects_invalid_phone(self): + """Invalid phone numbers should raise ValidationError.""" + with pytest.raises(ValidationError): + UserUpdateModel(phone_number="123") + + +class TestUserReadModel: + """Tests for user read model.""" + def test_defaults_for_optional_fields(self): + """Optional fields should default to None or False as configured.""" + user = UserReadModel(id=1, email="user@example.com") + assert user.status is None + assert user.is_superuser is False + + +class TestTokenResponseModel: + """Tests for token response model.""" + def test_default_token_type(self): + """Token type should default to bearer.""" + token = TokenResponseModel( + access_token="access", + access_token_expiration="2025-01-01 00:00:00", + refresh_token="refresh", + refresh_token_expiration="2025-01-02 00:00:00", + ) + assert token.token_type == "bearer" + + +class TestUserQueryParams: + """Tests for user query params.""" + def test_inherits_pagination_defaults(self): + """Pagination defaults should be applied.""" + params = UserQueryParams(page=2) + assert params.limit == 10 + assert params.offset() == 10 + + +class TestPasswordModels: + """Tests for password reset models.""" + def test_password_reset_requires_valid_email(self): + """Invalid emails should raise ValidationError.""" + with pytest.raises(ValidationError): + PasswordResetRequest(email="bad-email") + + def test_otp_validation_accepts_value(self): + """OTP should be preserved as provided.""" + request = OTPValidationRequest(otp="123456") + assert request.otp == "123456" diff --git a/tests/userverse_models/test_userverse_models.py b/tests/userverse_models/test_userverse_models.py deleted file mode 100644 index 12a24ed..0000000 --- a/tests/userverse_models/test_userverse_models.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest -from pydantic import ValidationError - -from src.generic_models.generic_pagination import PaginationParams -from src.userverse_models.validators.phone_number import validate_phone_number_format -from src.userverse_models.user.user import ( - TokenResponseModel, - UserCreateModel, - UserQueryParams, -) -from src.userverse_models.company.roles import CompanyDefaultRoles, RoleDeleteModel -from src.userverse_models.company.user import CompanyUserAddModel -from src.userverse_models.company.company import CompanyCreateModel - - -class TestPhoneNumberValidation: - """Tests for phone number validation.""" - - def test_accepts_none(self): - """Test that None is accepted and returns None.""" - assert validate_phone_number_format(None) is None - - def test_valid_number_returns_e164(self): - """Test that a valid phone number is returned in E.164 format.""" - assert validate_phone_number_format("+12025550123") == "+12025550123" - - def test_invalid_number_raises_value_error(self): - """Test that an invalid phone number raises a ValueError.""" - with pytest.raises(ValueError): - validate_phone_number_format("123") - - -class TestPaginationParams: - """Tests for PaginationParams model.""" - - def test_offset_calculation_defaults(self): - """Test that default pagination parameters calculate offset correctly.""" - params = PaginationParams() - assert params.offset() == 0 - - def test_offset_calculation_non_default(self): - """Test that non-default pagination parameters calculate offset correctly.""" - params = PaginationParams(limit=25, page=3) - assert params.offset() == 50 - - -class TestUserModels: - """Tests for user models.""" - - def test_user_create_validates_and_formats_phone_number(self): - """Test that UserCreateModel validates and formats phone number.""" - user = UserCreateModel(phone_number="+441234567890") - assert user.phone_number == "+441234567890" - - def test_user_query_params_inherit_pagination(self): - """Test that UserQueryParams inherits from PaginationParams.""" - params = UserQueryParams(page=2) - assert params.limit == 10 - assert params.offset() == 10 - - def test_token_response_default_token_type(self): - """Test that TokenResponseModel has default token_type 'bearer'.""" - token = TokenResponseModel( - access_token="access", - access_token_expiration="2025-01-01 00:00:00", - refresh_token="refresh", - refresh_token_expiration="2025-01-02 00:00:00", - ) - assert token.token_type == "bearer" - - -class TestCompanyModels: - """Tests for company models.""" - - def test_company_create_validates_phone_number(self): - """Test that CompanyCreateModel validates phone number.""" - company = CompanyCreateModel( - phone_number="+12025550123", - email="info@example.com", - ) - assert company.phone_number == "+12025550123" - - -class TestCompanyRoles: - """Tests for company role models.""" - - def test_default_role_properties(self): - """Test that CompanyDefaultRoles properties return correct values.""" - assert CompanyDefaultRoles.ADMINISTRATOR.name_value == "Administrator" - assert ( - CompanyDefaultRoles.ADMINISTRATOR.description - == "Full access to manage users and data" - ) - - def test_role_delete_rejects_default_role(self): - """Test that RoleDeleteModel raises ValidationError when deleting a default role.""" - with pytest.raises(ValidationError): - RoleDeleteModel( - replacement_role_name="Manager", - role_name_to_delete=CompanyDefaultRoles.ADMINISTRATOR.name_value, - ) - - -class TestCompanyUser: - """Tests for company user models.""" - - def test_company_user_add_default_role(self): - """Test that CompanyUserAddModel assigns default role if none provided.""" - company_user = CompanyUserAddModel(email="user@example.com") - assert company_user.role == CompanyDefaultRoles.VIEWER.name_value From 4c94d76f1178c36a22ea1ea077757e92e7ca8a62 Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 11:17:48 +0000 Subject: [PATCH 7/9] validators testing --- tests/validators/ReadMe.md | 18 +++++++++++++++++ tests/validators/__init__.py | 0 tests/validators/test_phone_number.py | 28 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/validators/ReadMe.md create mode 100644 tests/validators/__init__.py create mode 100644 tests/validators/test_phone_number.py diff --git a/tests/validators/ReadMe.md b/tests/validators/ReadMe.md new file mode 100644 index 0000000..a9208ca --- /dev/null +++ b/tests/validators/ReadMe.md @@ -0,0 +1,18 @@ +# Validators Tests + +These tests cover shared validation helpers in `src/validators`: +- `phone_number.py`: phone number validation and E.164 formatting + +## How to run +From the repository root: + +```bash +pytest tests/validators +``` + +From the module folder: + +```bash +cd tests/validators +pytest . +``` diff --git a/tests/validators/__init__.py b/tests/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/validators/test_phone_number.py b/tests/validators/test_phone_number.py new file mode 100644 index 0000000..218df3b --- /dev/null +++ b/tests/validators/test_phone_number.py @@ -0,0 +1,28 @@ +import pytest + +from src.validators.phone_number import validate_phone_number_format + + +class TestValidatePhoneNumberFormat: + """Tests for phone number validation and formatting.""" + def test_none_returns_none(self): + """None should pass through unchanged.""" + assert validate_phone_number_format(None) is None + + def test_empty_string_returns_empty(self): + """Empty values should pass through unchanged.""" + assert validate_phone_number_format("") == "" + + def test_valid_number_formats_to_e164(self): + """Valid phone numbers should be formatted to E.164.""" + assert validate_phone_number_format("+12025550123") == "+12025550123" + + def test_invalid_number_raises_value_error(self): + """Invalid phone numbers should raise ValueError.""" + with pytest.raises(ValueError): + validate_phone_number_format("123") + + def test_invalid_format_raises_value_error(self): + """Non-numeric strings should raise ValueError.""" + with pytest.raises(ValueError): + validate_phone_number_format("not-a-number") From e6c895674551bf381803e17fc58a7995dbaf9708 Mon Sep 17 00:00:00 2001 From: Skhendle Date: Sun, 4 Jan 2026 11:31:44 +0000 Subject: [PATCH 8/9] test suite fix --- README.md | 5 ++ pyproject.toml | 3 ++ src/userverse_models/company/company.py | 2 +- src/userverse_models/company/roles.py | 2 +- src/userverse_models/user/user.py | 2 +- .../validators/phone_number.py | 2 +- tests/ReadMe.md | 50 +++++++++++++++++-- tests/generic_models/test_generic_models.py | 6 +-- tests/userverse_models/test_company.py | 6 +-- tests/userverse_models/test_roles.py | 2 +- tests/userverse_models/test_user.py | 4 +- tests/validators/test_phone_number.py | 2 +- 12 files changed, 68 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e69de29..2f82916 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,5 @@ +# how to run the package after git clone +# how the to use the package in separate projects, pyndatic version +# running and discussing test + +# TODO: adding shared fast-api utils \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1e1d4f1..cdd20c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,6 @@ dependencies = [ [project.optional-dependencies] test = ["pytest>=8.3.3"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/userverse_models/company/company.py b/src/userverse_models/company/company.py index 2d294e3..ad3d5cb 100644 --- a/src/userverse_models/company/company.py +++ b/src/userverse_models/company/company.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, EmailStr, field_validator, Field -from src.generic_models.generic_pagination import PaginationParams +from generic_models.generic_pagination import PaginationParams from .address import CompanyAddressModel from ..validators.phone_number import validate_phone_number_format diff --git a/src/userverse_models/company/roles.py b/src/userverse_models/company/roles.py index cc8ef8b..70e07ea 100644 --- a/src/userverse_models/company/roles.py +++ b/src/userverse_models/company/roles.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from pydantic import field_validator, Field -from src.generic_models.generic_pagination import PaginationParams +from generic_models.generic_pagination import PaginationParams class CompanyDefaultRoles(str, Enum): diff --git a/src/userverse_models/user/user.py b/src/userverse_models/user/user.py index f009f5a..860f4b3 100644 --- a/src/userverse_models/user/user.py +++ b/src/userverse_models/user/user.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, EmailStr, field_validator, Field -from src.generic_models.generic_pagination import PaginationParams +from generic_models.generic_pagination import PaginationParams from ..validators.phone_number import validate_phone_number_format diff --git a/src/userverse_models/validators/phone_number.py b/src/userverse_models/validators/phone_number.py index 340ed24..e11b94a 100644 --- a/src/userverse_models/validators/phone_number.py +++ b/src/userverse_models/validators/phone_number.py @@ -1,5 +1,5 @@ """Compatibility shim for userverse model phone validation.""" -from src.validators.phone_number import validate_phone_number_format +from validators.phone_number import validate_phone_number_format __all__ = ["validate_phone_number_format"] diff --git a/tests/ReadMe.md b/tests/ReadMe.md index 2f82916..c6b1a22 100644 --- a/tests/ReadMe.md +++ b/tests/ReadMe.md @@ -1,5 +1,47 @@ -# how to run the package after git clone -# how the to use the package in separate projects, pyndatic version -# running and discussing test +# Test Suite Overview -# TODO: adding shared fast-api utils \ No newline at end of file +This folder contains the full automated test suite for the shared models package. +Tests are grouped by domain and mirror the structure under `src/`. + +## Test layout + +- `tests/generic_models/`: Tests for generic response, pagination, and errors. +- `tests/userverse_models/`: Tests for user, company, and related domain models. +- `tests/validators/`: Tests for shared validators (for example, phone numbers). + +## Run the full test suite + +From the project root: + +```bash +source .venv/bin/activate +pytest +``` + +If you use `uv` to manage the virtual environment: + +```bash +uv venv +source .venv/bin/activate +uv sync +pytest +``` + +## Run a subset + +Run a specific folder: + +```bash +pytest tests/validators +``` + +Run a single file: + +```bash +pytest tests/userverse_models/test_user.py +``` + +## Notes + +- Pytest is configured to include `src` on the import path via `pyproject.toml`. +- If you see import errors, verify the virtual environment is active and dependencies are installed. diff --git a/tests/generic_models/test_generic_models.py b/tests/generic_models/test_generic_models.py index 135aa4e..d9c9159 100644 --- a/tests/generic_models/test_generic_models.py +++ b/tests/generic_models/test_generic_models.py @@ -1,15 +1,15 @@ import pytest from pydantic import BaseModel, ValidationError -from src.generic_models.app_error import AppErrorResponseModel, DetailModel -from src.generic_models.generic_pagination import ( +from generic_models.app_error import AppErrorResponseModel, DetailModel +from generic_models.generic_pagination import ( FilterLogic, MatchType, PaginatedResponse, PaginationMeta, PaginationParams, ) -from src.generic_models.generic_response import GenericResponseModel +from generic_models.generic_response import GenericResponseModel class Item(BaseModel): diff --git a/tests/userverse_models/test_company.py b/tests/userverse_models/test_company.py index d091a16..465f9ba 100644 --- a/tests/userverse_models/test_company.py +++ b/tests/userverse_models/test_company.py @@ -1,14 +1,14 @@ import pytest from pydantic import ValidationError -from src.userverse_models.company.address import CompanyAddressModel -from src.userverse_models.company.company import ( +from userverse_models.company.address import CompanyAddressModel +from userverse_models.company.company import ( CompanyCreateModel, CompanyQueryParamsModel, CompanyReadModel, CompanyUpdateModel, ) -from src.userverse_models.company.user import CompanyUserAddModel, CompanyUserReadModel +from userverse_models.company.user import CompanyUserAddModel, CompanyUserReadModel class TestCompanyAddressModel: diff --git a/tests/userverse_models/test_roles.py b/tests/userverse_models/test_roles.py index 28f4809..4bf7ab5 100644 --- a/tests/userverse_models/test_roles.py +++ b/tests/userverse_models/test_roles.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from src.userverse_models.company.roles import ( +from userverse_models.company.roles import ( CompanyDefaultRoles, RoleCreateModel, RoleDeleteModel, diff --git a/tests/userverse_models/test_user.py b/tests/userverse_models/test_user.py index bdaa1e8..cf02630 100644 --- a/tests/userverse_models/test_user.py +++ b/tests/userverse_models/test_user.py @@ -1,8 +1,8 @@ import pytest from pydantic import ValidationError -from src.userverse_models.user.password import OTPValidationRequest, PasswordResetRequest -from src.userverse_models.user.user import ( +from userverse_models.user.password import OTPValidationRequest, PasswordResetRequest +from userverse_models.user.user import ( TokenResponseModel, UserCreateModel, UserLoginModel, diff --git a/tests/validators/test_phone_number.py b/tests/validators/test_phone_number.py index 218df3b..03603ea 100644 --- a/tests/validators/test_phone_number.py +++ b/tests/validators/test_phone_number.py @@ -1,6 +1,6 @@ import pytest -from src.validators.phone_number import validate_phone_number_format +from validators.phone_number import validate_phone_number_format class TestValidatePhoneNumberFormat: From 3958affc979856de93006f8f8328c8d8b3ef4a96 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 4 Jan 2026 11:38:02 +0000 Subject: [PATCH 9/9] style: auto-format code with Black --- tests/generic_models/test_generic_models.py | 6 ++++++ tests/userverse_models/test_company.py | 7 ++++++- tests/userverse_models/test_roles.py | 2 ++ tests/userverse_models/test_user.py | 7 +++++++ tests/validators/test_phone_number.py | 1 + 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/generic_models/test_generic_models.py b/tests/generic_models/test_generic_models.py index d9c9159..b83eab4 100644 --- a/tests/generic_models/test_generic_models.py +++ b/tests/generic_models/test_generic_models.py @@ -14,11 +14,13 @@ class Item(BaseModel): """Simple record type for pagination tests.""" + id: int class TestAppErrorModels: """Tests for application error models.""" + def test_detail_model_roundtrip(self): """DetailModel should preserve message and error fields.""" detail = DetailModel(message="Not found", error="missing_resource") @@ -34,6 +36,7 @@ def test_missing_detail_raises_validation_error(self): class TestGenericResponseModel: """Tests for GenericResponseModel.""" + def test_generic_response_with_data(self): """GenericResponseModel should accept typed data.""" response = GenericResponseModel[str](message="ok", data="value") @@ -53,6 +56,7 @@ def test_generic_response_requires_data_field(self): class TestPaginationEnums: """Tests for pagination enums.""" + def test_match_type_values(self): """MatchType should expose expected string values.""" assert MatchType.PARTIAL.value == "partial" @@ -67,6 +71,7 @@ def test_filter_logic_values(self): class TestPaginationParams: """Tests for PaginationParams.""" + def test_defaults(self): """Default pagination values should be limit 10, page 1.""" params = PaginationParams() @@ -94,6 +99,7 @@ def test_page_out_of_bounds_raises(self, page): class TestPaginatedResponse: """Tests for PaginatedResponse and PaginationMeta.""" + def test_paginated_response_structure(self): """PaginatedResponse should accept records and pagination metadata.""" meta = PaginationMeta( diff --git a/tests/userverse_models/test_company.py b/tests/userverse_models/test_company.py index 465f9ba..0cd8c02 100644 --- a/tests/userverse_models/test_company.py +++ b/tests/userverse_models/test_company.py @@ -13,6 +13,7 @@ class TestCompanyAddressModel: """Tests for company address model.""" + def test_optional_fields_default_to_none(self): """Address fields should default to None when not provided.""" address = CompanyAddressModel() @@ -40,9 +41,12 @@ def test_address_accepts_values(self): class TestCompanyModels: """Tests for company models.""" + def test_company_create_formats_phone_number(self): """Phone number should be formatted to E.164.""" - company = CompanyCreateModel(phone_number="+12025550123", email="info@example.com") + company = CompanyCreateModel( + phone_number="+12025550123", email="info@example.com" + ) assert company.phone_number == "+12025550123" def test_company_update_rejects_invalid_phone(self): @@ -64,6 +68,7 @@ def test_company_query_params_defaults(self): class TestCompanyUserModels: """Tests for company user models.""" + def test_company_user_add_defaults_role(self): """Role should default to Viewer when not provided.""" user = CompanyUserAddModel(email="user@example.com") diff --git a/tests/userverse_models/test_roles.py b/tests/userverse_models/test_roles.py index 4bf7ab5..39950f5 100644 --- a/tests/userverse_models/test_roles.py +++ b/tests/userverse_models/test_roles.py @@ -13,6 +13,7 @@ class TestCompanyDefaultRoles: """Tests for default roles enum.""" + def test_name_and_description_properties(self): """Enum properties should split name and description.""" assert CompanyDefaultRoles.ADMINISTRATOR.name_value == "Administrator" @@ -24,6 +25,7 @@ def test_name_and_description_properties(self): class TestRoleModels: """Tests for role models.""" + def test_role_create_roundtrip(self): """RoleCreateModel should preserve provided values.""" role = RoleCreateModel(name="Manager", description="Manages users") diff --git a/tests/userverse_models/test_user.py b/tests/userverse_models/test_user.py index cf02630..a9fd3a8 100644 --- a/tests/userverse_models/test_user.py +++ b/tests/userverse_models/test_user.py @@ -14,6 +14,7 @@ class TestUserLoginModel: """Tests for user login model.""" + def test_login_requires_valid_email(self): """Invalid email should raise a ValidationError.""" with pytest.raises(ValidationError): @@ -22,6 +23,7 @@ def test_login_requires_valid_email(self): class TestUserCreateModel: """Tests for user creation model.""" + def test_user_create_formats_phone_number(self): """Phone numbers should be formatted to E.164.""" user = UserCreateModel(phone_number="+12025550123") @@ -30,6 +32,7 @@ def test_user_create_formats_phone_number(self): class TestUserUpdateModel: """Tests for user update model.""" + def test_user_update_rejects_invalid_phone(self): """Invalid phone numbers should raise ValidationError.""" with pytest.raises(ValidationError): @@ -38,6 +41,7 @@ def test_user_update_rejects_invalid_phone(self): class TestUserReadModel: """Tests for user read model.""" + def test_defaults_for_optional_fields(self): """Optional fields should default to None or False as configured.""" user = UserReadModel(id=1, email="user@example.com") @@ -47,6 +51,7 @@ def test_defaults_for_optional_fields(self): class TestTokenResponseModel: """Tests for token response model.""" + def test_default_token_type(self): """Token type should default to bearer.""" token = TokenResponseModel( @@ -60,6 +65,7 @@ def test_default_token_type(self): class TestUserQueryParams: """Tests for user query params.""" + def test_inherits_pagination_defaults(self): """Pagination defaults should be applied.""" params = UserQueryParams(page=2) @@ -69,6 +75,7 @@ def test_inherits_pagination_defaults(self): class TestPasswordModels: """Tests for password reset models.""" + def test_password_reset_requires_valid_email(self): """Invalid emails should raise ValidationError.""" with pytest.raises(ValidationError): diff --git a/tests/validators/test_phone_number.py b/tests/validators/test_phone_number.py index 03603ea..7e47b0f 100644 --- a/tests/validators/test_phone_number.py +++ b/tests/validators/test_phone_number.py @@ -5,6 +5,7 @@ class TestValidatePhoneNumberFormat: """Tests for phone number validation and formatting.""" + def test_none_returns_none(self): """None should pass through unchanged.""" assert validate_phone_number_format(None) is None