From 880aa5ac25f8cb51dbbcfb66bb758ce4724a94d2 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 14:30:54 +0200 Subject: [PATCH 1/6] fix: remove _id from replacement doc in repository update methods MongoDB's replace_one raises a BSONError when the replacement document includes an _id field with a different type than the stored document (e.g. string vs ObjectId). Strip _id from the doc before replacing so the filter alone identifies the target document. --- .../additional_training_repository.py | 1 + .../repositories/certification_repository.py | 1 + .../contact_information_repository.py | 1 + .../contact_message_repository.py | 1 + .../repositories/education_repository.py | 1 + .../repositories/experience_repository.py | 1 + .../repositories/language_repository.py | 1 + .../repositories/profile_repository.py | 1 + .../programming_language_repository.py | 1 + .../repositories/project_repository.py | 1 + .../repositories/skill_repository.py | 1 + .../repositories/social_network_repository.py | 1 + .../repositories/tool_repository.py | 1 + .../repositories/test_profile_repository.py | 19 +++++++++++++++++++ 14 files changed, 32 insertions(+) diff --git a/app/infrastructure/repositories/additional_training_repository.py b/app/infrastructure/repositories/additional_training_repository.py index 1d7ac0b..ccec388 100644 --- a/app/infrastructure/repositories/additional_training_repository.py +++ b/app/infrastructure/repositories/additional_training_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: AdditionalTraining) -> AdditionalTraining: async def update(self, entity: AdditionalTraining) -> AdditionalTraining: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/certification_repository.py b/app/infrastructure/repositories/certification_repository.py index f7e7e82..69889d1 100644 --- a/app/infrastructure/repositories/certification_repository.py +++ b/app/infrastructure/repositories/certification_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Certification) -> Certification: async def update(self, entity: Certification) -> Certification: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/contact_information_repository.py b/app/infrastructure/repositories/contact_information_repository.py index 4c9967b..ff5c0c6 100644 --- a/app/infrastructure/repositories/contact_information_repository.py +++ b/app/infrastructure/repositories/contact_information_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: ContactInformation) -> ContactInformation: async def update(self, entity: ContactInformation) -> ContactInformation: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/contact_message_repository.py b/app/infrastructure/repositories/contact_message_repository.py index b315c1b..20bf98c 100644 --- a/app/infrastructure/repositories/contact_message_repository.py +++ b/app/infrastructure/repositories/contact_message_repository.py @@ -25,6 +25,7 @@ async def add(self, entity: ContactMessage) -> ContactMessage: async def update(self, entity: ContactMessage) -> ContactMessage: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/education_repository.py b/app/infrastructure/repositories/education_repository.py index d9b8e89..88b1484 100644 --- a/app/infrastructure/repositories/education_repository.py +++ b/app/infrastructure/repositories/education_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Education) -> Education: async def update(self, entity: Education) -> Education: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/experience_repository.py b/app/infrastructure/repositories/experience_repository.py index 0c6862e..83f70f9 100644 --- a/app/infrastructure/repositories/experience_repository.py +++ b/app/infrastructure/repositories/experience_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: WorkExperience) -> WorkExperience: async def update(self, entity: WorkExperience) -> WorkExperience: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/language_repository.py b/app/infrastructure/repositories/language_repository.py index 115e45c..418bc9c 100644 --- a/app/infrastructure/repositories/language_repository.py +++ b/app/infrastructure/repositories/language_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Language) -> Language: async def update(self, entity: Language) -> Language: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/profile_repository.py b/app/infrastructure/repositories/profile_repository.py index bb6b4ec..8096b17 100644 --- a/app/infrastructure/repositories/profile_repository.py +++ b/app/infrastructure/repositories/profile_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Profile) -> Profile: async def update(self, entity: Profile) -> Profile: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/programming_language_repository.py b/app/infrastructure/repositories/programming_language_repository.py index a1ba068..c5562b2 100644 --- a/app/infrastructure/repositories/programming_language_repository.py +++ b/app/infrastructure/repositories/programming_language_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: ProgrammingLanguage) -> ProgrammingLanguage: async def update(self, entity: ProgrammingLanguage) -> ProgrammingLanguage: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/project_repository.py b/app/infrastructure/repositories/project_repository.py index c6afec5..1207f2a 100644 --- a/app/infrastructure/repositories/project_repository.py +++ b/app/infrastructure/repositories/project_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Project) -> Project: async def update(self, entity: Project) -> Project: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/skill_repository.py b/app/infrastructure/repositories/skill_repository.py index 9b868d1..4e0e581 100644 --- a/app/infrastructure/repositories/skill_repository.py +++ b/app/infrastructure/repositories/skill_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Skill) -> Skill: async def update(self, entity: Skill) -> Skill: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/social_network_repository.py b/app/infrastructure/repositories/social_network_repository.py index 89b2110..4e7d88d 100644 --- a/app/infrastructure/repositories/social_network_repository.py +++ b/app/infrastructure/repositories/social_network_repository.py @@ -29,6 +29,7 @@ async def add(self, entity: SocialNetwork) -> SocialNetwork: async def update(self, entity: SocialNetwork) -> SocialNetwork: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/app/infrastructure/repositories/tool_repository.py b/app/infrastructure/repositories/tool_repository.py index 2c0c936..0f8fcdf 100644 --- a/app/infrastructure/repositories/tool_repository.py +++ b/app/infrastructure/repositories/tool_repository.py @@ -24,6 +24,7 @@ async def add(self, entity: Tool) -> Tool: async def update(self, entity: Tool) -> Tool: doc = self._mapper.to_persistence(entity) + doc.pop("_id", None) await self._collection.replace_one({"_id": entity.id}, doc) return entity diff --git a/tests/unit/infrastructure/repositories/test_profile_repository.py b/tests/unit/infrastructure/repositories/test_profile_repository.py index a11d63a..de25f71 100644 --- a/tests/unit/infrastructure/repositories/test_profile_repository.py +++ b/tests/unit/infrastructure/repositories/test_profile_repository.py @@ -91,6 +91,25 @@ async def test_update_calls_replace_one(self, repo, collection): assert call_args[0][0] == {"_id": "p-1"} assert result is entity + @pytest.mark.asyncio + async def test_update_excludes_id_from_replacement_doc(self, repo, collection): + """_id must not appear in the replacement document to avoid BSONError + when the stored _id type (e.g. ObjectId) differs from the filter string.""" + entity = MagicMock() + entity.id = "p-1" + + with patch.object( + repo._mapper, + "to_persistence", + return_value={"_id": "p-1", "name": "John", "headline": "Dev"}, + ): + await repo.update(entity) + + _filter, replacement = collection.replace_one.call_args[0] + assert "_id" not in replacement, ( + "replacement doc must not contain _id to prevent BSONError" + ) + class TestProfileRepositoryDelete: @pytest.mark.asyncio From 6c356e292336c410448938a56b17f7821c0daa08 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 15:34:58 +0200 Subject: [PATCH 2/6] fix(test): fixed format + lint + sort imports --- .../infrastructure/repositories/test_profile_repository.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/infrastructure/repositories/test_profile_repository.py b/tests/unit/infrastructure/repositories/test_profile_repository.py index de25f71..33e90b3 100644 --- a/tests/unit/infrastructure/repositories/test_profile_repository.py +++ b/tests/unit/infrastructure/repositories/test_profile_repository.py @@ -106,9 +106,9 @@ async def test_update_excludes_id_from_replacement_doc(self, repo, collection): await repo.update(entity) _filter, replacement = collection.replace_one.call_args[0] - assert "_id" not in replacement, ( - "replacement doc must not contain _id to prevent BSONError" - ) + assert ( + "_id" not in replacement + ), "replacement doc must not contain _id to prevent BSONError" class TestProfileRepositoryDelete: From e28556ab71376131daea4141d9bc9815bea492d4 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 16:00:22 +0200 Subject: [PATCH 3/6] test: increase unit test coverage from 95% to 96.74% --- .../api/test_exception_handlers_direct.py | 324 ++++++++++++++++ .../test_additional_training_extended.py | 270 +++++++++++++ .../domain/entities/test_profile_extended.py | 185 +++++++++ .../domain/entities/test_project_extended.py | 251 +++++++++++++ .../domain/entities/test_skill_extended.py | 168 +++++++++ .../entities/test_work_experience_extended.py | 354 ++++++++++++++++++ tests/unit/domain/test_domain_errors.py | 108 ++++++ .../value_objects/test_date_range_extended.py | 274 ++++++++++++++ .../value_objects/test_email_extended.py | 146 ++++++++ .../test_language_proficiency_extended.py | 97 +++++ .../test_skill_level_extended.py | 238 ++++++++++++ 11 files changed, 2415 insertions(+) create mode 100644 tests/unit/api/test_exception_handlers_direct.py create mode 100644 tests/unit/domain/entities/test_additional_training_extended.py create mode 100644 tests/unit/domain/entities/test_profile_extended.py create mode 100644 tests/unit/domain/entities/test_project_extended.py create mode 100644 tests/unit/domain/entities/test_skill_extended.py create mode 100644 tests/unit/domain/entities/test_work_experience_extended.py create mode 100644 tests/unit/domain/test_domain_errors.py create mode 100644 tests/unit/domain/value_objects/test_date_range_extended.py create mode 100644 tests/unit/domain/value_objects/test_email_extended.py create mode 100644 tests/unit/domain/value_objects/test_language_proficiency_extended.py create mode 100644 tests/unit/domain/value_objects/test_skill_level_extended.py diff --git a/tests/unit/api/test_exception_handlers_direct.py b/tests/unit/api/test_exception_handlers_direct.py new file mode 100644 index 0000000..34db209 --- /dev/null +++ b/tests/unit/api/test_exception_handlers_direct.py @@ -0,0 +1,324 @@ +""" +Direct unit tests for exception handler functions. + +These tests call the handler functions directly (not via HTTP) to achieve +high coverage of each handler branch without requiring the full ASGI stack. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from app.api.exception_handlers import ( + application_exception_handler, + business_rule_exception_handler, + domain_error_handler, + duplicate_exception_handler, + forbidden_exception_handler, + generic_exception_handler, + not_found_exception_handler, + request_validation_handler, + unauthorized_exception_handler, + validation_exception_handler, +) +from app.domain.exceptions.domain_errors import DomainError +from app.shared.shared_exceptions import ( + ApplicationException, + BusinessRuleViolationException, + DuplicateException, + ForbiddenException, + NotFoundException, + UnauthorizedException, + ValidationException, +) + + +def _make_request(): + """Return a minimal mock Request object.""" + return MagicMock() + + +def _parse(response): + """Decode JSONResponse body.""" + return json.loads(response.body) + + +@pytest.mark.unit +class TestNotFoundHandlerDirect: + """Direct tests for not_found_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_404(self): + response = await not_found_exception_handler( + _make_request(), NotFoundException("Profile", "abc") + ) + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await not_found_exception_handler( + _make_request(), NotFoundException("Skill", "sk1") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Not Found" + assert body["code"] == "NOT_FOUND" + assert isinstance(body["message"], str) + + +@pytest.mark.unit +class TestValidationHandlerDirect: + """Direct tests for validation_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_422(self): + response = await validation_exception_handler( + _make_request(), ValidationException(["bad input"]) + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await validation_exception_handler( + _make_request(), ValidationException(["name is required"]) + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Validation Error" + assert body["code"] == "VALIDATION_ERROR" + + +@pytest.mark.unit +class TestDuplicateHandlerDirect: + """Direct tests for duplicate_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_409(self): + response = await duplicate_exception_handler( + _make_request(), DuplicateException("Tool", "name", "Docker") + ) + assert response.status_code == 409 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await duplicate_exception_handler( + _make_request(), DuplicateException("Tool", "name", "Docker") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Conflict" + assert body["code"] == "DUPLICATE" + + +@pytest.mark.unit +class TestUnauthorizedHandlerDirect: + """Direct tests for unauthorized_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_401(self): + response = await unauthorized_exception_handler( + _make_request(), UnauthorizedException("token expired") + ) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await unauthorized_exception_handler( + _make_request(), UnauthorizedException("not authenticated") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Unauthorized" + assert body["code"] == "UNAUTHORIZED" + + +@pytest.mark.unit +class TestForbiddenHandlerDirect: + """Direct tests for forbidden_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_403(self): + response = await forbidden_exception_handler( + _make_request(), ForbiddenException("access denied") + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await forbidden_exception_handler( + _make_request(), ForbiddenException("not allowed") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Forbidden" + assert body["code"] == "FORBIDDEN" + + +@pytest.mark.unit +class TestBusinessRuleHandlerDirect: + """Direct tests for business_rule_exception_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_400(self): + response = await business_rule_exception_handler( + _make_request(), BusinessRuleViolationException("rule violated") + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await business_rule_exception_handler( + _make_request(), BusinessRuleViolationException("cannot delete profile") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Bad Request" + assert body["code"] == "BUSINESS_RULE_VIOLATION" + + +@pytest.mark.unit +class TestApplicationExceptionHandlerDirect: + """Direct tests for application_exception_handler (500 catch-all).""" + + @pytest.mark.asyncio + async def test_status_code_is_500(self): + response = await application_exception_handler( + _make_request(), ApplicationException("unexpected") + ) + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await application_exception_handler( + _make_request(), ApplicationException("internal error") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Internal Server Error" + assert body["code"] == "APPLICATION_ERROR" + + +@pytest.mark.unit +class TestDomainErrorHandlerDirect: + """Direct tests for domain_error_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_400(self): + class SomeDomainError(DomainError): + def __init__(self): + super().__init__("invariant violated") + + response = await domain_error_handler(_make_request(), SomeDomainError()) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_body_fields(self): + class SomeDomainError(DomainError): + def __init__(self): + super().__init__("name cannot be empty") + + response = await domain_error_handler(_make_request(), SomeDomainError()) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Bad Request" + assert body["code"] == "DOMAIN_ERROR" + assert "name cannot be empty" in body["message"] + + +@pytest.mark.unit +class TestGenericExceptionHandlerDirect: + """Direct tests for generic_exception_handler (final fallback).""" + + @pytest.mark.asyncio + async def test_status_code_is_500(self): + response = await generic_exception_handler( + _make_request(), RuntimeError("boom") + ) + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_body_fields(self): + response = await generic_exception_handler( + _make_request(), ValueError("unexpected state") + ) + body = _parse(response) + assert body["success"] is False + assert body["error"] == "Internal Server Error" + assert body["code"] == "INTERNAL_ERROR" + assert "unexpected" in body["message"].lower() + + +@pytest.mark.unit +class TestRequestValidationHandlerDirect: + """Direct tests for request_validation_handler.""" + + @pytest.mark.asyncio + async def test_status_code_is_422(self): + from fastapi.exceptions import RequestValidationError + + exc = RequestValidationError( + errors=[ + { + "loc": ("body", "name"), + "msg": "field required", + "type": "value_error.missing", + "input": {}, + "url": "", + } + ] + ) + response = await request_validation_handler(_make_request(), exc) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_combines_multiple_errors_into_message(self): + from fastapi.exceptions import RequestValidationError + + exc = RequestValidationError( + errors=[ + { + "loc": ("body", "email"), + "msg": "field required", + "type": "value_error.missing", + "input": {}, + "url": "", + }, + { + "loc": ("body", "name"), + "msg": "field required", + "type": "value_error.missing", + "input": {}, + "url": "", + }, + ] + ) + response = await request_validation_handler(_make_request(), exc) + body = _parse(response) + + assert body["code"] == "VALIDATION_ERROR" + # Both field names should appear in the combined message + assert "email" in body["message"] + assert "name" in body["message"] + + @pytest.mark.asyncio + async def test_body_has_expected_structure(self): + from fastapi.exceptions import RequestValidationError + + exc = RequestValidationError( + errors=[ + { + "loc": ("body", "order_index"), + "msg": "value is not a valid integer", + "type": "type_error.integer", + "input": "abc", + "url": "", + } + ] + ) + response = await request_validation_handler(_make_request(), exc) + body = _parse(response) + + assert body["success"] is False + assert body["error"] == "Validation Error" + assert "order_index" in body["message"] diff --git a/tests/unit/domain/entities/test_additional_training_extended.py b/tests/unit/domain/entities/test_additional_training_extended.py new file mode 100644 index 0000000..384ae74 --- /dev/null +++ b/tests/unit/domain/entities/test_additional_training_extended.py @@ -0,0 +1,270 @@ +""" +Extended tests for AdditionalTraining Entity. + +Covers uncovered lines: empty string normalization for duration/certificate_url/description, +update_technologies(), update_order(), update_info() with individual fields, +technology too long, and __repr__. +""" + +from datetime import datetime + +import pytest + +from app.domain.entities.additional_training import AdditionalTraining +from app.domain.exceptions import ( + EmptyFieldError, + InvalidLengthError, + InvalidOrderIndexError, + InvalidProviderError, + InvalidTitleError, + InvalidURLError, +) + +COMPLETION_DATE = datetime(2024, 1, 15) + + +def _make_training(profile_id, **kwargs): + defaults = { + "profile_id": profile_id, + "title": "Python Course", + "provider": "Udemy", + "completion_date": COMPLETION_DATE, + "order_index": 0, + } + defaults.update(kwargs) + return AdditionalTraining.create(**defaults) + + +@pytest.mark.entity +class TestAdditionalTrainingEmptyStringNormalization: + """Test that optional fields with empty strings are normalized to None.""" + + def test_empty_duration_normalized_to_none(self, profile_id): + """Empty duration should be stored as None.""" + training = _make_training(profile_id, duration="") + + assert training.duration is None + + def test_whitespace_duration_normalized_to_none(self, profile_id): + """Whitespace duration should be stored as None.""" + training = _make_training(profile_id, duration=" ") + + assert training.duration is None + + def test_empty_certificate_url_normalized_to_none(self, profile_id): + """Empty certificate_url should be stored as None.""" + training = _make_training(profile_id, certificate_url="") + + assert training.certificate_url is None + + def test_whitespace_certificate_url_normalized_to_none(self, profile_id): + """Whitespace certificate_url should be stored as None.""" + training = _make_training(profile_id, certificate_url=" ") + + assert training.certificate_url is None + + def test_empty_description_normalized_to_none(self, profile_id): + """Empty description should be stored as None.""" + training = _make_training(profile_id, description="") + + assert training.description is None + + def test_whitespace_description_normalized_to_none(self, profile_id): + """Whitespace description should be stored as None.""" + training = _make_training(profile_id, description=" ") + + assert training.description is None + + +@pytest.mark.entity +@pytest.mark.business_rule +class TestAdditionalTrainingValidation: + """Test validation rules for AdditionalTraining.""" + + def test_empty_profile_id_at_update_raises_error(self): + """Empty profile_id should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + AdditionalTraining.create( + profile_id=" ", + title="Course", + provider="Udemy", + completion_date=COMPLETION_DATE, + order_index=0, + ) + + def test_empty_title_raises_error(self, profile_id): + """Empty title should raise InvalidTitleError.""" + with pytest.raises(InvalidTitleError): + _make_training(profile_id, title="") + + def test_title_too_long_raises_error(self, profile_id): + """Title > 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, title="x" * 101) + + def test_empty_provider_raises_error(self, profile_id): + """Empty provider should raise InvalidProviderError.""" + with pytest.raises(InvalidProviderError): + _make_training(profile_id, provider="") + + def test_provider_too_long_raises_error(self, profile_id): + """Provider > 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, provider="x" * 101) + + def test_duration_too_long_raises_error(self, profile_id): + """Duration > 50 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, duration="x" * 51) + + def test_invalid_certificate_url_raises_error(self, profile_id): + """Invalid URL format for certificate_url should raise InvalidURLError.""" + with pytest.raises(InvalidURLError): + _make_training(profile_id, certificate_url="not-a-url") + + def test_description_too_long_raises_error(self, profile_id): + """Description > 500 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, description="x" * 501) + + def test_negative_order_index_raises_error(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + with pytest.raises(InvalidOrderIndexError): + _make_training(profile_id, order_index=-1) + + def test_too_many_technologies_raises_error(self, profile_id): + """More than 20 technologies should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, technologies=[f"Tech{i}" for i in range(21)]) + + def test_empty_technology_item_raises_error(self, profile_id): + """Empty string in technologies list should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + _make_training(profile_id, technologies=["Python", ""]) + + def test_technology_item_too_long_raises_error(self, profile_id): + """Technology item > 150 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_training(profile_id, technologies=["x" * 151]) + + +@pytest.mark.entity +class TestAdditionalTrainingUpdateInfo: + """Test update_info() method.""" + + def test_update_title(self, profile_id): + """Should update title.""" + training = _make_training(profile_id) + training.update_info(title="New Title") + + assert training.title == "New Title" + + def test_update_provider(self, profile_id): + """Should update provider.""" + training = _make_training(profile_id) + training.update_info(provider="Coursera") + + assert training.provider == "Coursera" + + def test_update_completion_date(self, profile_id): + """Should update completion_date.""" + training = _make_training(profile_id) + new_date = datetime(2025, 6, 1) + training.update_info(completion_date=new_date) + + assert training.completion_date == new_date + + def test_update_duration(self, profile_id): + """Should update duration.""" + training = _make_training(profile_id) + training.update_info(duration="60h") + + assert training.duration == "60h" + + def test_update_certificate_url(self, profile_id): + """Should update certificate_url.""" + training = _make_training(profile_id) + training.update_info(certificate_url="https://example.com/cert") + + assert training.certificate_url == "https://example.com/cert" + + def test_update_description(self, profile_id): + """Should update description.""" + training = _make_training(profile_id) + training.update_info(description="New description") + + assert training.description == "New description" + + def test_update_info_invalid_title_raises(self, profile_id): + """Updating with invalid title should raise error.""" + training = _make_training(profile_id) + + with pytest.raises(InvalidTitleError): + training.update_info(title="") + + def test_update_info_invalid_certificate_url_raises(self, profile_id): + """Updating with invalid URL should raise error.""" + training = _make_training(profile_id) + + with pytest.raises(InvalidURLError): + training.update_info(certificate_url="not-a-url") + + +@pytest.mark.entity +class TestAdditionalTrainingUpdateTechnologies: + """Test update_technologies() method.""" + + def test_update_technologies(self, profile_id): + """Should replace technologies list.""" + training = _make_training(profile_id, technologies=["Python"]) + training.update_technologies(["FastAPI", "MongoDB"]) + + assert training.technologies == ["FastAPI", "MongoDB"] + + def test_update_technologies_empty_list(self, profile_id): + """Should accept empty list.""" + training = _make_training(profile_id, technologies=["Python"]) + training.update_technologies([]) + + assert training.technologies == [] + + def test_update_technologies_invalid_raises(self, profile_id): + """Updating with invalid technologies should raise error.""" + training = _make_training(profile_id) + + with pytest.raises(InvalidLengthError): + training.update_technologies([f"Tech{i}" for i in range(21)]) + + +@pytest.mark.entity +class TestAdditionalTrainingUpdateOrder: + """Test update_order() method.""" + + def test_update_order(self, profile_id): + """Should update order_index.""" + training = _make_training(profile_id, order_index=0) + training.update_order(5) + + assert training.order_index == 5 + + def test_update_order_negative_raises(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + training = _make_training(profile_id) + + with pytest.raises(InvalidOrderIndexError): + training.update_order(-1) + + +@pytest.mark.entity +class TestAdditionalTrainingRepr: + """Test __repr__ method.""" + + def test_repr_contains_relevant_fields(self, profile_id): + """__repr__ should include id, title, and provider.""" + training = _make_training(profile_id) + + result = repr(training) + + assert "AdditionalTraining" in result + assert "Python Course" in result + assert "Udemy" in result diff --git a/tests/unit/domain/entities/test_profile_extended.py b/tests/unit/domain/entities/test_profile_extended.py new file mode 100644 index 0000000..ba392d2 --- /dev/null +++ b/tests/unit/domain/entities/test_profile_extended.py @@ -0,0 +1,185 @@ +""" +Extended tests for Profile Entity. + +Covers uncovered lines: update_avatar(None), empty bio/location normalization, +location too long, avatar_url empty string normalization, update_basic_info with bio. +""" + +import pytest + +from app.domain.entities.profile import Profile +from app.domain.exceptions import EmptyFieldError, InvalidLengthError, InvalidURLError + + +@pytest.mark.entity +class TestProfileEmptyStringNormalization: + """Test that empty strings are normalized to None.""" + + def test_empty_bio_normalized_to_none(self): + """Empty bio string should be stored as None.""" + profile = Profile.create(name="John", headline="Engineer", bio="") + + assert profile.bio is None + + def test_whitespace_bio_normalized_to_none(self): + """Whitespace-only bio should be normalized to None.""" + profile = Profile.create(name="John", headline="Engineer", bio=" ") + + assert profile.bio is None + + def test_empty_location_normalized_to_none(self): + """Empty location string should be stored as None.""" + profile = Profile.create(name="John", headline="Engineer", location="") + + assert profile.location is None + + def test_whitespace_location_normalized_to_none(self): + """Whitespace-only location should be normalized to None.""" + profile = Profile.create(name="John", headline="Engineer", location=" ") + + assert profile.location is None + + def test_empty_avatar_url_normalized_to_none(self): + """Empty avatar_url string should be stored as None.""" + profile = Profile.create(name="John", headline="Engineer", avatar_url="") + + assert profile.avatar_url is None + + +@pytest.mark.entity +@pytest.mark.business_rule +class TestProfileValidationBoundaries: + """Test Profile field length boundaries.""" + + def test_location_too_long_raises_error(self): + """Location longer than 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + Profile.create( + name="John", + headline="Engineer", + location="x" * 101, + ) + + def test_headline_too_long_raises_error(self): + """Headline longer than 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + Profile.create(name="John", headline="x" * 101) + + def test_whitespace_name_raises_error(self): + """Whitespace-only name should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + Profile.create(name=" ", headline="Engineer") + + def test_whitespace_headline_raises_error(self): + """Whitespace-only headline should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + Profile.create(name="John", headline=" ") + + +@pytest.mark.entity +class TestProfileUpdateAvatar: + """Test update_avatar() method.""" + + def test_update_avatar_to_none_removes_url(self): + """Setting avatar to None should clear it.""" + profile = Profile.create( + name="John", + headline="Engineer", + avatar_url="https://example.com/avatar.jpg", + ) + + profile.update_avatar(None) + + assert profile.avatar_url is None + + def test_update_avatar_with_valid_url(self): + """Setting avatar to a valid URL should update it.""" + profile = Profile.create(name="John", headline="Engineer") + + profile.update_avatar("https://cdn.example.com/pic.png") + + assert profile.avatar_url == "https://cdn.example.com/pic.png" + + def test_update_avatar_with_invalid_url_raises_error(self): + """Setting avatar to an invalid URL should raise InvalidURLError.""" + profile = Profile.create(name="John", headline="Engineer") + + with pytest.raises(InvalidURLError): + profile.update_avatar("not-a-url") + + def test_update_avatar_updates_timestamp(self): + """update_avatar() should update the updated_at timestamp.""" + from datetime import datetime + + profile = Profile.create(name="John", headline="Engineer") + before = profile.updated_at + + profile.update_avatar("https://example.com/pic.jpg") + + assert profile.updated_at >= before + + +@pytest.mark.entity +class TestProfileUpdateBasicInfo: + """Extended tests for update_basic_info().""" + + def test_update_bio(self): + """Should update bio field.""" + profile = Profile.create(name="John", headline="Engineer") + + profile.update_basic_info(bio="New bio text") + + assert profile.bio == "New bio text" + + def test_update_location(self): + """Should update location field.""" + profile = Profile.create(name="John", headline="Engineer") + + profile.update_basic_info(location="Barcelona, Spain") + + assert profile.location == "Barcelona, Spain" + + def test_update_with_no_args_is_idempotent(self): + """Calling update_basic_info() with no args should not change anything.""" + profile = Profile.create( + name="John", + headline="Engineer", + bio="Bio", + location="Madrid", + ) + original_name = profile.name + original_headline = profile.headline + + profile.update_basic_info() + + assert profile.name == original_name + assert profile.headline == original_headline + + def test_update_name_validates_length(self): + """Updating name with too long value should raise InvalidLengthError.""" + profile = Profile.create(name="John", headline="Engineer") + + with pytest.raises(InvalidLengthError): + profile.update_basic_info(name="x" * 101) + + def test_update_headline_validates_length(self): + """Updating headline with too long value should raise InvalidLengthError.""" + profile = Profile.create(name="John", headline="Engineer") + + with pytest.raises(InvalidLengthError): + profile.update_basic_info(headline="x" * 101) + + +@pytest.mark.entity +class TestProfileRepr: + """Test __repr__ method.""" + + def test_repr_contains_name_and_headline(self): + """__repr__ should include id, name, and headline.""" + profile = Profile.create(name="Alex", headline="Developer") + + result = repr(profile) + + assert "Profile" in result + assert "Alex" in result + assert "Developer" in result diff --git a/tests/unit/domain/entities/test_project_extended.py b/tests/unit/domain/entities/test_project_extended.py new file mode 100644 index 0000000..87a7229 --- /dev/null +++ b/tests/unit/domain/entities/test_project_extended.py @@ -0,0 +1,251 @@ +""" +Extended tests for Project Entity. + +Covers uncovered lines: update_order(), add_technology() when full or too long, +URL empty string normalization, update_info() with start/end dates, __repr__. +""" + +from datetime import datetime + +import pytest + +from app.domain.entities.project import Project +from app.domain.exceptions import ( + EmptyFieldError, + InvalidDateRangeError, + InvalidDescriptionError, + InvalidLengthError, + InvalidOrderIndexError, + InvalidTitleError, + InvalidURLError, +) + +LONG_DESC = "A" * 100 + + +def _make_project(profile_id, **kwargs): + defaults = { + "profile_id": profile_id, + "title": "My Project", + "description": LONG_DESC, + "start_date": datetime(2023, 1, 1), + "order_index": 0, + } + defaults.update(kwargs) + return Project.create(**defaults) + + +@pytest.mark.entity +@pytest.mark.business_rule +class TestProjectProfileIdValidation: + """Test profile_id validation at both creation and update.""" + + def test_empty_profile_id_at_creation_raises_error(self): + """Empty profile_id should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + Project.create( + profile_id="", + title="Test", + description=LONG_DESC, + start_date=datetime(2023, 1, 1), + order_index=0, + ) + + def test_technology_item_too_long_in_validate_raises_error(self, profile_id): + """Technology item > 150 chars in validation should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + Project.create( + profile_id=profile_id, + title="Test", + description=LONG_DESC, + start_date=datetime(2023, 1, 1), + order_index=0, + technologies=["x" * 151], + ) + + +@pytest.mark.entity +class TestProjectURLNormalization: + """Test that empty URL strings are normalized to None.""" + + def test_empty_live_url_normalized_to_none(self, profile_id): + """Empty live_url string should be stored as None.""" + project = _make_project(profile_id, live_url="") + + assert project.live_url is None + + def test_whitespace_live_url_normalized_to_none(self, profile_id): + """Whitespace live_url should be stored as None.""" + project = _make_project(profile_id, live_url=" ") + + assert project.live_url is None + + def test_empty_repo_url_normalized_to_none(self, profile_id): + """Empty repo_url string should be stored as None.""" + project = _make_project(profile_id, repo_url="") + + assert project.repo_url is None + + def test_whitespace_repo_url_normalized_to_none(self, profile_id): + """Whitespace repo_url should be stored as None.""" + project = _make_project(profile_id, repo_url=" ") + + assert project.repo_url is None + + +@pytest.mark.entity +class TestProjectUpdateOrder: + """Test update_order() method.""" + + def test_update_order_changes_index(self, profile_id): + """Should update order_index.""" + project = _make_project(profile_id, order_index=0) + + project.update_order(3) + + assert project.order_index == 3 + + def test_update_order_negative_raises(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + project = _make_project(profile_id) + + with pytest.raises(InvalidOrderIndexError): + project.update_order(-1) + + def test_update_order_updates_timestamp(self, profile_id): + """update_order() should update the updated_at timestamp.""" + project = _make_project(profile_id) + before = project.updated_at + + project.update_order(2) + + assert project.updated_at >= before + + +@pytest.mark.entity +class TestProjectAddTechnology: + """Extended tests for add_technology().""" + + def test_add_technology_when_at_max_raises(self, profile_id): + """Adding technology when list is already at max should raise InvalidLengthError.""" + project = _make_project( + profile_id, + technologies=[f"Tech{i}" for i in range(20)], + ) + + with pytest.raises(InvalidLengthError): + project.add_technology("OneMore") + + def test_add_technology_too_long_raises(self, profile_id): + """Adding a technology name > 150 chars should raise InvalidLengthError.""" + project = _make_project(profile_id) + + with pytest.raises(InvalidLengthError): + project.add_technology("x" * 151) + + def test_add_technology_updates_timestamp(self, profile_id): + """add_technology() should update the updated_at timestamp.""" + project = _make_project(profile_id) + before = project.updated_at + + project.add_technology("Docker") + + assert project.updated_at >= before + + +@pytest.mark.entity +class TestProjectUpdateInfoWithDates: + """Test update_info() with date parameters.""" + + def test_update_start_date(self, profile_id): + """Should update start_date.""" + project = _make_project(profile_id, start_date=datetime(2023, 1, 1)) + new_start = datetime(2022, 6, 1) + project.update_info(start_date=new_start) + + assert project.start_date == new_start + + def test_update_end_date(self, profile_id): + """Should update end_date.""" + project = _make_project(profile_id) + new_end = datetime(2023, 12, 31) + project.update_info(end_date=new_end) + + assert project.end_date == new_end + + def test_update_info_invalid_title_raises(self, profile_id): + """Updating with empty title should raise InvalidTitleError.""" + project = _make_project(profile_id) + + with pytest.raises(InvalidTitleError): + project.update_info(title="") + + def test_update_info_invalid_description_raises(self, profile_id): + """Updating with short description (no URLs) should raise InvalidDescriptionError.""" + project = _make_project(profile_id) + + with pytest.raises((InvalidDescriptionError, InvalidLengthError)): + project.update_info(description="Short") + + def test_update_info_invalid_date_order_raises(self, profile_id): + """Setting end_date before start_date should raise InvalidDateRangeError.""" + project = _make_project(profile_id, start_date=datetime(2023, 6, 1)) + + with pytest.raises(InvalidDateRangeError): + project.update_info(end_date=datetime(2022, 1, 1)) + + +@pytest.mark.entity +class TestProjectUpdateURLs: + """Extended tests for update_urls().""" + + def test_update_both_urls(self, profile_id): + """Should update both URLs.""" + project = _make_project(profile_id) + project.update_urls( + live_url="https://example.com", + repo_url="https://github.com/user/repo", + ) + + assert project.live_url == "https://example.com" + assert project.repo_url == "https://github.com/user/repo" + + def test_update_url_empty_string_normalized_to_none(self, profile_id): + """Updating URL with empty string should normalize to None.""" + project = _make_project( + profile_id, + live_url="https://example.com", + ) + project.update_urls(live_url="") + + assert project.live_url is None + + def test_update_url_invalid_repo_url_raises(self, profile_id): + """Invalid repo_url should raise InvalidURLError.""" + project = _make_project(profile_id) + + with pytest.raises(InvalidURLError): + project.update_urls(repo_url="ftp://invalid") + + def test_update_urls_updates_timestamp(self, profile_id): + """update_urls() should update the updated_at timestamp.""" + project = _make_project(profile_id) + before = project.updated_at + + project.update_urls(live_url="https://example.com") + + assert project.updated_at >= before + + +@pytest.mark.entity +class TestProjectRepr: + """Test __repr__ method.""" + + def test_repr_contains_title(self, profile_id): + """__repr__ should include id and title.""" + project = _make_project(profile_id, title="My Amazing Project") + + result = repr(project) + + assert "Project" in result + assert "My Amazing Project" in result diff --git a/tests/unit/domain/entities/test_skill_extended.py b/tests/unit/domain/entities/test_skill_extended.py new file mode 100644 index 0000000..3087690 --- /dev/null +++ b/tests/unit/domain/entities/test_skill_extended.py @@ -0,0 +1,168 @@ +""" +Extended tests for Skill Entity. + +Covers uncovered lines: update_order(), remove_level(), level with whitespace +normalization to None, empty profile_id validation, and __repr__. +""" + +import pytest + +from app.domain.entities.skill import Skill +from app.domain.exceptions import ( + EmptyFieldError, + InvalidLengthError, + InvalidNameError, + InvalidOrderIndexError, + InvalidSkillLevelError, +) + + +@pytest.mark.entity +class TestSkillLevelNormalization: + """Test level field normalization.""" + + def test_whitespace_level_normalized_to_none(self, profile_id): + """Level with only whitespace should be stored as None.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level=" ") + + assert skill.level is None + + def test_level_normalized_to_lowercase(self, profile_id): + """Level string should be normalized to lowercase.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level="ADVANCED") + + assert skill.level == "advanced" + + def test_none_level_stays_none(self, profile_id): + """None level should remain None.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level=None) + + assert skill.level is None + + +@pytest.mark.entity +@pytest.mark.business_rule +class TestSkillValidationEdgeCases: + """Test validation edge cases not covered in base tests.""" + + def test_empty_profile_id_raises_error(self): + """Empty profile_id should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + Skill.create(profile_id="", name="Python", order_index=0) + + def test_whitespace_profile_id_raises_error(self): + """Whitespace-only profile_id should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + Skill.create(profile_id=" ", name="Python", order_index=0) + + def test_whitespace_name_raises_error(self, profile_id): + """Whitespace-only name should raise InvalidNameError.""" + with pytest.raises(InvalidNameError): + Skill.create(profile_id=profile_id, name=" ", order_index=0) + + def test_negative_order_index_raises_error(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + with pytest.raises(InvalidOrderIndexError): + Skill.create(profile_id=profile_id, name="Python", order_index=-1) + + +@pytest.mark.entity +class TestSkillUpdateOrder: + """Test update_order() method.""" + + def test_update_order_changes_index(self, profile_id): + """Should update order_index.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0) + + skill.update_order(5) + + assert skill.order_index == 5 + + def test_update_order_negative_raises_error(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0) + + with pytest.raises(InvalidOrderIndexError): + skill.update_order(-1) + + def test_update_order_updates_timestamp(self, profile_id): + """update_order() should update the updated_at timestamp.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0) + before = skill.updated_at + + skill.update_order(2) + + assert skill.updated_at >= before + + +@pytest.mark.entity +class TestSkillRemoveLevel: + """Test remove_level() method.""" + + def test_remove_level_clears_level(self, profile_id): + """remove_level() should set level to None.""" + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level="expert" + ) + + skill.remove_level() + + assert skill.level is None + + def test_remove_level_updates_timestamp(self, profile_id): + """remove_level() should update the updated_at timestamp.""" + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level="basic" + ) + before = skill.updated_at + + skill.remove_level() + + assert skill.updated_at >= before + + +@pytest.mark.entity +class TestSkillUpdateInfo: + """Extended tests for update_info().""" + + def test_update_info_validates_new_level(self, profile_id): + """Updating with an invalid level should raise InvalidSkillLevelError.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0) + + with pytest.raises(InvalidSkillLevelError): + skill.update_info(level="guru") + + def test_update_info_validates_new_name(self, profile_id): + """Updating with empty name should raise InvalidNameError.""" + skill = Skill.create(profile_id=profile_id, name="Python", order_index=0) + + with pytest.raises(InvalidNameError): + skill.update_info(name="") + + def test_update_info_with_no_args_is_safe(self, profile_id): + """Calling update_info() with no args should not change anything except timestamp.""" + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level="expert" + ) + + skill.update_info() + + assert skill.name == "Python" + assert skill.level == "expert" + + +@pytest.mark.entity +class TestSkillRepr: + """Test __repr__ method.""" + + def test_repr_contains_name_and_level(self, profile_id): + """__repr__ should include id, name and level.""" + skill = Skill.create( + profile_id=profile_id, name="Docker", order_index=1, level="advanced" + ) + + result = repr(skill) + + assert "Skill" in result + assert "Docker" in result + assert "advanced" in result diff --git a/tests/unit/domain/entities/test_work_experience_extended.py b/tests/unit/domain/entities/test_work_experience_extended.py new file mode 100644 index 0000000..6d20faa --- /dev/null +++ b/tests/unit/domain/entities/test_work_experience_extended.py @@ -0,0 +1,354 @@ +""" +Extended tests for WorkExperience Entity. + +Covers uncovered lines: empty string normalization for description/location, +add_responsibility(), update_responsibilities(), update_technologies(), update_order(), +is_current_position(), location too long, and __repr__. +""" + +from datetime import datetime + +import pytest + +from app.domain.entities.work_experience import WorkExperience +from app.domain.exceptions import ( + EmptyFieldError, + InvalidCompanyError, + InvalidDateRangeError, + InvalidLengthError, + InvalidOrderIndexError, + InvalidRoleError, +) + +START = datetime(2022, 1, 1) +END = datetime(2023, 6, 30) + + +def _make_experience(profile_id, **kwargs): + defaults = { + "profile_id": profile_id, + "role": "Backend Developer", + "company": "Tech Corp", + "start_date": START, + "order_index": 0, + } + defaults.update(kwargs) + return WorkExperience.create(**defaults) + + +@pytest.mark.entity +class TestWorkExperienceEmptyStringNormalization: + """Test that optional fields with empty strings normalize to None.""" + + def test_empty_description_normalized_to_none(self, profile_id): + """Empty description should be stored as None.""" + exp = _make_experience(profile_id, description="") + + assert exp.description is None + + def test_whitespace_description_normalized_to_none(self, profile_id): + """Whitespace-only description should be stored as None.""" + exp = _make_experience(profile_id, description=" ") + + assert exp.description is None + + +@pytest.mark.entity +@pytest.mark.business_rule +class TestWorkExperienceValidation: + """Test validation rules for WorkExperience.""" + + def test_empty_role_raises_error(self, profile_id): + """Empty role should raise InvalidRoleError.""" + with pytest.raises(InvalidRoleError): + _make_experience(profile_id, role="") + + def test_role_too_long_raises_error(self, profile_id): + """Role > 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, role="x" * 101) + + def test_empty_company_raises_error(self, profile_id): + """Empty company should raise InvalidCompanyError.""" + with pytest.raises(InvalidCompanyError): + _make_experience(profile_id, company="") + + def test_company_too_long_raises_error(self, profile_id): + """Company > 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, company="x" * 101) + + def test_description_too_long_raises_error(self, profile_id): + """Description > 2000 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, description="x" * 2001) + + def test_location_too_long_raises_error(self, profile_id): + """Location > 100 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, location="x" * 101) + + def test_end_date_before_start_raises_error(self, profile_id): + """end_date before start_date should raise InvalidDateRangeError.""" + with pytest.raises(InvalidDateRangeError): + _make_experience( + profile_id, + start_date=datetime(2023, 1, 1), + end_date=datetime(2022, 1, 1), + ) + + def test_negative_order_index_raises_error(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + with pytest.raises(InvalidOrderIndexError): + _make_experience(profile_id, order_index=-1) + + def test_too_many_responsibilities_raises_error(self, profile_id): + """More than 20 responsibilities should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience( + profile_id, + responsibilities=[f"Task {i}" for i in range(21)], + ) + + def test_empty_responsibility_item_raises_error(self, profile_id): + """Empty string in responsibilities should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + _make_experience(profile_id, responsibilities=["valid task", ""]) + + def test_responsibility_item_too_long_raises_error(self, profile_id): + """Responsibility item > 500 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, responsibilities=["x" * 501]) + + def test_too_many_technologies_raises_error(self, profile_id): + """More than 20 technologies should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience( + profile_id, + technologies=[f"Tech{i}" for i in range(21)], + ) + + def test_empty_technology_item_raises_error(self, profile_id): + """Empty string in technologies list should raise EmptyFieldError.""" + with pytest.raises(EmptyFieldError): + _make_experience(profile_id, technologies=["Python", ""]) + + def test_technology_item_too_long_raises_error(self, profile_id): + """Technology item > 150 chars should raise InvalidLengthError.""" + with pytest.raises(InvalidLengthError): + _make_experience(profile_id, technologies=["x" * 151]) + + +@pytest.mark.entity +class TestWorkExperienceIsCurrentPosition: + """Test is_current_position() method.""" + + def test_is_current_when_no_end_date(self, profile_id): + """Should return True when no end_date is set.""" + exp = _make_experience(profile_id) + + assert exp.is_current_position() is True + + def test_is_not_current_when_end_date_set(self, profile_id): + """Should return False when end_date is set.""" + exp = _make_experience(profile_id, end_date=END) + + assert exp.is_current_position() is False + + +@pytest.mark.entity +class TestWorkExperienceUpdateInfo: + """Test update_info() method extended scenarios.""" + + def test_update_role(self, profile_id): + """Should update role field.""" + exp = _make_experience(profile_id) + exp.update_info(role="Senior Backend Developer") + + assert exp.role == "Senior Backend Developer" + + def test_update_company(self, profile_id): + """Should update company field.""" + exp = _make_experience(profile_id) + exp.update_info(company="New Company") + + assert exp.company == "New Company" + + def test_update_description(self, profile_id): + """Should update description.""" + exp = _make_experience(profile_id) + exp.update_info(description="New description for the role") + + assert exp.description == "New description for the role" + + def test_update_location(self, profile_id): + """Should update location.""" + exp = _make_experience(profile_id) + exp.update_info(location="Barcelona, Spain") + + assert exp.location == "Barcelona, Spain" + + def test_update_start_date(self, profile_id): + """Should update start_date.""" + exp = _make_experience(profile_id) + new_start = datetime(2021, 6, 1) + exp.update_info(start_date=new_start) + + assert exp.start_date == new_start + + def test_update_end_date(self, profile_id): + """Should update end_date.""" + exp = _make_experience(profile_id) + exp.update_info(end_date=END) + + assert exp.end_date == END + + def test_update_info_invalid_dates_raises(self, profile_id): + """Setting end_date before start_date should raise error.""" + exp = _make_experience(profile_id, start_date=datetime(2023, 1, 1)) + + with pytest.raises(InvalidDateRangeError): + exp.update_info(end_date=datetime(2022, 1, 1)) + + def test_update_info_invalid_role_raises(self, profile_id): + """Empty role should raise InvalidRoleError.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidRoleError): + exp.update_info(role="") + + def test_update_info_invalid_company_raises(self, profile_id): + """Empty company should raise error.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidCompanyError): + exp.update_info(company="") + + def test_update_info_invalid_description_raises(self, profile_id): + """Too-long description should raise error.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidLengthError): + exp.update_info(description="x" * 2001) + + def test_update_info_invalid_location_raises(self, profile_id): + """Too-long location should raise error.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidLengthError): + exp.update_info(location="x" * 101) + + +@pytest.mark.entity +class TestWorkExperienceAddResponsibility: + """Test add_responsibility() method.""" + + def test_add_responsibility(self, profile_id): + """Should add a responsibility to the list.""" + exp = _make_experience(profile_id) + exp.add_responsibility("Design system architecture") + + assert "Design system architecture" in exp.responsibilities + + def test_add_empty_responsibility_raises(self, profile_id): + """Empty responsibility should raise EmptyFieldError.""" + exp = _make_experience(profile_id) + + with pytest.raises(EmptyFieldError): + exp.add_responsibility("") + + def test_add_too_long_responsibility_raises(self, profile_id): + """Responsibility > 500 chars should raise InvalidLengthError.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidLengthError): + exp.add_responsibility("x" * 501) + + def test_add_responsibility_when_full_raises(self, profile_id): + """Adding more than 20 responsibilities should raise InvalidLengthError.""" + exp = _make_experience( + profile_id, + responsibilities=[f"Task {i}" for i in range(20)], + ) + + with pytest.raises(InvalidLengthError): + exp.add_responsibility("One more task") + + +@pytest.mark.entity +class TestWorkExperienceUpdateResponsibilities: + """Test update_responsibilities() method.""" + + def test_update_responsibilities(self, profile_id): + """Should replace the responsibilities list.""" + exp = _make_experience(profile_id, responsibilities=["Old task"]) + exp.update_responsibilities(["New task 1", "New task 2"]) + + assert exp.responsibilities == ["New task 1", "New task 2"] + + def test_update_responsibilities_empty_list(self, profile_id): + """Should accept empty list.""" + exp = _make_experience(profile_id, responsibilities=["Some task"]) + exp.update_responsibilities([]) + + assert exp.responsibilities == [] + + def test_update_responsibilities_invalid_raises(self, profile_id): + """Invalid items should raise error.""" + exp = _make_experience(profile_id) + + with pytest.raises(EmptyFieldError): + exp.update_responsibilities(["valid", ""]) + + +@pytest.mark.entity +class TestWorkExperienceUpdateTechnologies: + """Test update_technologies() method.""" + + def test_update_technologies(self, profile_id): + """Should replace the technologies list.""" + exp = _make_experience(profile_id, technologies=["Python"]) + exp.update_technologies(["FastAPI", "MongoDB", "Docker"]) + + assert exp.technologies == ["FastAPI", "MongoDB", "Docker"] + + def test_update_technologies_invalid_raises(self, profile_id): + """More than 20 items should raise error.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidLengthError): + exp.update_technologies([f"Tech{i}" for i in range(21)]) + + +@pytest.mark.entity +class TestWorkExperienceUpdateOrder: + """Test update_order() method.""" + + def test_update_order(self, profile_id): + """Should update order_index.""" + exp = _make_experience(profile_id, order_index=0) + exp.update_order(3) + + assert exp.order_index == 3 + + def test_update_order_negative_raises(self, profile_id): + """Negative order_index should raise InvalidOrderIndexError.""" + exp = _make_experience(profile_id) + + with pytest.raises(InvalidOrderIndexError): + exp.update_order(-1) + + +@pytest.mark.entity +class TestWorkExperienceRepr: + """Test __repr__ method.""" + + def test_repr_contains_role_and_company(self, profile_id): + """__repr__ should include role and company.""" + exp = _make_experience(profile_id) + + result = repr(exp) + + assert "WorkExperience" in result + assert "Backend Developer" in result + assert "Tech Corp" in result diff --git a/tests/unit/domain/test_domain_errors.py b/tests/unit/domain/test_domain_errors.py new file mode 100644 index 0000000..a08f156 --- /dev/null +++ b/tests/unit/domain/test_domain_errors.py @@ -0,0 +1,108 @@ +""" +Tests for domain error classes. + +Covers all branches of InvalidLengthError and verifies each domain error +is a proper subclass of DomainError. +""" + +import pytest + +from app.domain.exceptions.domain_errors import ( + DomainError, + DuplicateValueError, + EmptyFieldError, + InvalidCategoryError, + InvalidCompanyError, + InvalidDateRangeError, + InvalidDescriptionError, + InvalidEmailError, + InvalidInstitutionError, + InvalidIssuerError, + InvalidLanguageProficiencyError, + InvalidLengthError, + InvalidNameError, + InvalidOrderIndexError, + InvalidPhoneError, + InvalidPlatformError, + InvalidProgrammingLanguageLevelError, + InvalidProviderError, + InvalidRoleError, + InvalidSkillLevelError, + InvalidTitleError, + InvalidURLError, +) + + +@pytest.mark.unit +class TestInvalidLengthErrorMessages: + """Test InvalidLengthError message branches.""" + + def test_min_length_message(self): + """Should produce 'at least N characters' message when min_length given.""" + err = InvalidLengthError("description", min_length=10) + + assert "at least 10 characters" in str(err) + assert "description" in str(err) + + def test_max_length_message(self): + """Should produce 'exceeds maximum length' message when max_length given.""" + err = InvalidLengthError("name", max_length=50) + + assert "exceeds maximum length of 50" in str(err) + assert "name" in str(err) + + def test_no_length_message(self): + """Should produce generic message when neither min nor max given.""" + err = InvalidLengthError("field") + + assert "Invalid length for field" in str(err) + + def test_is_domain_error(self): + """InvalidLengthError should be a DomainError.""" + err = InvalidLengthError("field", max_length=100) + + assert isinstance(err, DomainError) + + +@pytest.mark.unit +class TestDomainErrorSubclasses: + """Verify all domain error classes are proper DomainError subclasses.""" + + @pytest.mark.parametrize( + "error_class", + [ + EmptyFieldError, + InvalidURLError, + InvalidDateRangeError, + InvalidOrderIndexError, + InvalidNameError, + InvalidTitleError, + InvalidDescriptionError, + InvalidCategoryError, + InvalidSkillLevelError, + InvalidRoleError, + InvalidCompanyError, + InvalidIssuerError, + InvalidInstitutionError, + InvalidEmailError, + InvalidPhoneError, + InvalidPlatformError, + InvalidProviderError, + DuplicateValueError, + InvalidProgrammingLanguageLevelError, + InvalidLanguageProficiencyError, + ], + ) + def test_is_domain_error_subclass(self, error_class): + """Every domain error should be a subclass of DomainError.""" + assert issubclass(error_class, DomainError) + + def test_empty_field_error_message(self): + """EmptyFieldError should store the field name.""" + err = EmptyFieldError("email") + assert "email" in str(err) + + def test_invalid_url_error_message(self): + """InvalidURLError should store the bad URL.""" + err = InvalidURLError("ftp://bad.url") + assert "ftp://bad.url" in str(err) diff --git a/tests/unit/domain/value_objects/test_date_range_extended.py b/tests/unit/domain/value_objects/test_date_range_extended.py new file mode 100644 index 0000000..f3035f5 --- /dev/null +++ b/tests/unit/domain/value_objects/test_date_range_extended.py @@ -0,0 +1,274 @@ +""" +Extended tests for DateRange Value Object. + +Covers uncovered methods: create(), ongoing(), completed(), contains_date(), +overlaps_with(), with_end_date(), duration_days() ongoing, and __str__/__repr__. +""" + +from datetime import datetime + +import pytest + +from app.domain.exceptions import EmptyFieldError, InvalidDateRangeError +from app.domain.value_objects.date_range import DateRange + + +@pytest.mark.value_object +class TestDateRangeFactoryMethods: + """Test all factory methods.""" + + def test_create_with_only_start_date(self): + """create() with no end date should produce an ongoing range.""" + start = datetime(2023, 1, 1) + dr = DateRange.create(start_date=start) + + assert dr.start_date == start + assert dr.end_date is None + assert dr.is_ongoing() is True + + def test_create_with_start_and_end_date(self): + """create() with both dates should produce a completed range.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 6, 1) + dr = DateRange.create(start_date=start, end_date=end) + + assert dr.start_date == start + assert dr.end_date == end + assert dr.is_completed() is True + + def test_create_raises_when_end_before_start(self): + """create() should raise InvalidDateRangeError when end <= start.""" + start = datetime(2023, 6, 1) + end = datetime(2023, 1, 1) + + with pytest.raises(InvalidDateRangeError): + DateRange.create(start_date=start, end_date=end) + + def test_ongoing_factory(self): + """ongoing() should produce a range with no end date.""" + start = datetime(2022, 3, 15) + dr = DateRange.ongoing(start) + + assert dr.is_ongoing() is True + assert dr.end_date is None + + def test_completed_factory(self): + """completed() should produce a range with both dates set.""" + start = datetime(2020, 1, 1) + end = datetime(2021, 1, 1) + dr = DateRange.completed(start, end) + + assert dr.is_completed() is True + assert dr.end_date == end + + +@pytest.mark.value_object +class TestDateRangeDurationOngoing: + """Test duration_days() for ongoing ranges.""" + + def test_duration_days_returns_none_when_ongoing(self): + """duration_days() should return None for ongoing ranges.""" + dr = DateRange.ongoing(datetime(2023, 1, 1)) + + assert dr.duration_days() is None + + +@pytest.mark.value_object +class TestDateRangeContainsDate: + """Test contains_date() method.""" + + def test_date_within_completed_range(self): + """Should return True for a date inside the range.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + assert dr.contains_date(datetime(2023, 6, 15)) is True + + def test_date_on_start_boundary(self): + """Should return True for a date equal to start.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + assert dr.contains_date(start) is True + + def test_date_on_end_boundary(self): + """Should return True for a date equal to end.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + assert dr.contains_date(end) is True + + def test_date_before_range(self): + """Should return False for a date before start.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + assert dr.contains_date(datetime(2022, 12, 31)) is False + + def test_date_after_range(self): + """Should return False for a date after end.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + assert dr.contains_date(datetime(2024, 1, 1)) is False + + def test_ongoing_range_contains_future_date(self): + """Ongoing range should contain any date after start.""" + dr = DateRange.ongoing(datetime(2020, 1, 1)) + + assert dr.contains_date(datetime(2030, 1, 1)) is True + + def test_ongoing_range_does_not_contain_past_date(self): + """Ongoing range should not contain a date before start.""" + dr = DateRange.ongoing(datetime(2023, 1, 1)) + + assert dr.contains_date(datetime(2022, 12, 31)) is False + + +@pytest.mark.value_object +class TestDateRangeOverlapsWith: + """Test overlaps_with() method.""" + + def test_two_overlapping_completed_ranges(self): + """Should return True when ranges overlap.""" + dr1 = DateRange.completed(datetime(2023, 1, 1), datetime(2023, 6, 30)) + dr2 = DateRange.completed(datetime(2023, 3, 1), datetime(2023, 9, 30)) + + assert dr1.overlaps_with(dr2) is True + assert dr2.overlaps_with(dr1) is True + + def test_two_non_overlapping_completed_ranges(self): + """Should return False when ranges do not overlap.""" + dr1 = DateRange.completed(datetime(2022, 1, 1), datetime(2022, 6, 30)) + dr2 = DateRange.completed(datetime(2023, 1, 1), datetime(2023, 6, 30)) + + assert dr1.overlaps_with(dr2) is False + assert dr2.overlaps_with(dr1) is False + + def test_ongoing_range_overlaps_with_later_range(self): + """An ongoing range starting before another should overlap.""" + ongoing = DateRange.ongoing(datetime(2020, 1, 1)) + later = DateRange.completed(datetime(2023, 1, 1), datetime(2023, 12, 31)) + + assert ongoing.overlaps_with(later) is True + + def test_completed_range_overlaps_with_ongoing(self): + """A completed range should overlap with an ongoing range that starts before it.""" + completed = DateRange.completed(datetime(2023, 1, 1), datetime(2023, 12, 31)) + ongoing = DateRange.ongoing(datetime(2022, 6, 1)) + + assert completed.overlaps_with(ongoing) is True + + def test_two_ongoing_ranges_always_overlap(self): + """Two ongoing ranges always overlap.""" + dr1 = DateRange.ongoing(datetime(2020, 1, 1)) + dr2 = DateRange.ongoing(datetime(2021, 1, 1)) + + assert dr1.overlaps_with(dr2) is True + + def test_adjacent_ranges_do_not_overlap(self): + """Adjacent ranges (end == next start) should not overlap per strict comparison.""" + dr1 = DateRange.completed(datetime(2022, 1, 1), datetime(2022, 12, 31)) + dr2 = DateRange.completed(datetime(2023, 1, 1), datetime(2023, 12, 31)) + + # 2022-12-31 < 2023-01-01, so they should not overlap + assert dr1.overlaps_with(dr2) is False + + +@pytest.mark.value_object +class TestDateRangeWithEndDate: + """Test with_end_date() method.""" + + def test_creates_new_range_with_end_date(self): + """with_end_date() should return a new DateRange with the given end date.""" + start = datetime(2023, 1, 1) + dr = DateRange.ongoing(start) + + new_end = datetime(2023, 12, 31) + completed_dr = dr.with_end_date(new_end) + + assert completed_dr.start_date == start + assert completed_dr.end_date == new_end + assert completed_dr.is_completed() is True + + def test_original_range_unchanged(self): + """Original range should remain unchanged after with_end_date().""" + dr = DateRange.ongoing(datetime(2023, 1, 1)) + _ = dr.with_end_date(datetime(2023, 12, 31)) + + assert dr.is_ongoing() is True + + +@pytest.mark.value_object +class TestDateRangeIsCompleted: + """Test is_completed() method.""" + + def test_is_completed_true_with_end_date(self): + """is_completed() returns True for a range with end date.""" + dr = DateRange.completed(datetime(2022, 1, 1), datetime(2023, 1, 1)) + assert dr.is_completed() is True + + def test_is_completed_false_when_ongoing(self): + """is_completed() returns False for an ongoing range.""" + dr = DateRange.ongoing(datetime(2022, 1, 1)) + assert dr.is_completed() is False + + +@pytest.mark.value_object +class TestDateRangeStringRepresentations: + """Test __str__ and __repr__ methods.""" + + def test_str_ongoing_range(self): + """__str__ for ongoing range should end with 'Present'.""" + dr = DateRange.ongoing(datetime(2023, 1, 15)) + + result = str(dr) + + assert result.endswith("Present") + assert "2023-01-15" in result + + def test_str_completed_range(self): + """__str__ for completed range should show both dates.""" + dr = DateRange.completed(datetime(2022, 3, 1), datetime(2023, 9, 30)) + + result = str(dr) + + assert "2022-03-01" in result + assert "2023-09-30" in result + + def test_repr(self): + """__repr__ should include start and end date.""" + start = datetime(2023, 1, 1) + end = datetime(2023, 12, 31) + dr = DateRange.completed(start, end) + + result = repr(dr) + + assert "DateRange" in result + assert "start_date" in result + assert "end_date" in result + + +@pytest.mark.value_object +@pytest.mark.business_rule +class TestDateRangeValidationEdgeCases: + """Test validation edge cases.""" + + def test_equal_start_and_end_raises_error(self): + """end_date equal to start_date should raise InvalidDateRangeError.""" + same = datetime(2023, 6, 1) + + with pytest.raises(InvalidDateRangeError): + DateRange(start_date=same, end_date=same) + + def test_none_start_date_raises_empty_field_error(self): + """None start_date should raise EmptyFieldError.""" + from app.domain.exceptions import EmptyFieldError + + with pytest.raises(EmptyFieldError): + DateRange(start_date=None) # type: ignore[arg-type] diff --git a/tests/unit/domain/value_objects/test_email_extended.py b/tests/unit/domain/value_objects/test_email_extended.py new file mode 100644 index 0000000..b7b28ee --- /dev/null +++ b/tests/unit/domain/value_objects/test_email_extended.py @@ -0,0 +1,146 @@ +""" +Extended tests for Email Value Object. + +Covers uncovered methods: try_create(), get_local_part(), get_domain(), +is_from_domain(), and __repr__/__hash__. +""" + +import pytest + +from app.domain.exceptions import EmptyFieldError, InvalidEmailError +from app.domain.value_objects.email import Email + + +@pytest.mark.value_object +class TestEmailTryCreate: + """Test try_create() factory method.""" + + def test_try_create_valid_returns_email(self): + """try_create() with valid email should return Email instance.""" + result = Email.try_create("user@example.com") + + assert result is not None + assert result.value == "user@example.com" + + def test_try_create_invalid_returns_none(self): + """try_create() with invalid email should return None.""" + result = Email.try_create("not-an-email") + + assert result is None + + def test_try_create_empty_returns_none(self): + """try_create() with empty string should return None.""" + result = Email.try_create("") + + assert result is None + + def test_try_create_normalizes_case(self): + """try_create() should normalize to lowercase.""" + result = Email.try_create("USER@DOMAIN.COM") + + assert result is not None + assert result.value == "user@domain.com" + + +@pytest.mark.value_object +class TestEmailLocalAndDomain: + """Test get_local_part() and get_domain() methods.""" + + def test_get_local_part(self): + """Should return the part before @.""" + email = Email.create("alex@example.com") + + assert email.get_local_part() == "alex" + + def test_get_local_part_complex(self): + """Should handle complex local parts.""" + email = Email.create("alex.zapata+portfolio@mail.example.com") + + assert email.get_local_part() == "alex.zapata+portfolio" + + def test_get_domain(self): + """Should return the part after @.""" + email = Email.create("user@gmail.com") + + assert email.get_domain() == "gmail.com" + + def test_get_domain_subdomain(self): + """Should return full domain including subdomains.""" + email = Email.create("user@mail.company.org") + + assert email.get_domain() == "mail.company.org" + + +@pytest.mark.value_object +class TestEmailIsFromDomain: + """Test is_from_domain() method.""" + + def test_is_from_matching_domain(self): + """Should return True when email is from the specified domain.""" + email = Email.create("user@gmail.com") + + assert email.is_from_domain("gmail.com") is True + + def test_is_not_from_different_domain(self): + """Should return False when email is from a different domain.""" + email = Email.create("user@gmail.com") + + assert email.is_from_domain("outlook.com") is False + + def test_is_from_domain_case_insensitive(self): + """is_from_domain() should be case-insensitive.""" + email = Email.create("user@gmail.com") + + assert email.is_from_domain("GMAIL.COM") is True + assert email.is_from_domain("Gmail.Com") is True + + +@pytest.mark.value_object +class TestEmailHashAndRepr: + """Test __hash__ and __repr__ methods.""" + + def test_hash_equal_for_same_email(self): + """Two equal emails should have the same hash.""" + email1 = Email.create("user@example.com") + email2 = Email.create("user@example.com") + + assert hash(email1) == hash(email2) + + def test_hash_allows_use_in_set(self): + """Email should be usable in sets.""" + email1 = Email.create("user@example.com") + email2 = Email.create("user@example.com") + email3 = Email.create("other@example.com") + + email_set = {email1, email2, email3} + + assert len(email_set) == 2 + + def test_repr(self): + """__repr__ should return a useful debug representation.""" + email = Email.create("user@example.com") + + assert repr(email) == "Email('user@example.com')" + + def test_str(self): + """__str__ should return the email value.""" + email = Email.create("user@example.com") + + assert str(email) == "user@example.com" + + +@pytest.mark.value_object +class TestEmailNotEqualToOtherType: + """Test equality edge cases.""" + + def test_not_equal_to_string(self): + """Email should not be equal to a plain string.""" + email = Email.create("user@example.com") + + assert email != "user@example.com" + + def test_not_equal_to_none(self): + """Email should not be equal to None.""" + email = Email.create("user@example.com") + + assert email != None # noqa: E711 diff --git a/tests/unit/domain/value_objects/test_language_proficiency_extended.py b/tests/unit/domain/value_objects/test_language_proficiency_extended.py new file mode 100644 index 0000000..005b8b3 --- /dev/null +++ b/tests/unit/domain/value_objects/test_language_proficiency_extended.py @@ -0,0 +1,97 @@ +""" +Extended tests for LanguageProficiency Value Object. + +Covers uncovered comparison operator lines (returning NotImplemented for wrong types) +and ProgrammingLanguageLevel equivalent gap-filling. +""" + +import pytest + +from app.domain.value_objects.language_proficiency import ( + LanguageProficiency, + LanguageProficiencyEnum, +) +from app.domain.value_objects.programming_language_level import ( + ProgrammingLanguageLevel, + ProgrammingLanguageLevelEnum, +) + + +@pytest.mark.value_object +class TestLanguageProficiencyComparisonWithWrongType: + """Test that comparison operators return NotImplemented for non-LanguageProficiency.""" + + def test_lt_returns_not_implemented_for_wrong_type(self): + """__lt__ should return NotImplemented for non-LanguageProficiency.""" + result = LanguageProficiency.a1().__lt__("a1") + assert result is NotImplemented + + def test_le_returns_not_implemented_for_wrong_type(self): + """__le__ should return NotImplemented for non-LanguageProficiency.""" + result = LanguageProficiency.b1().__le__(42) + assert result is NotImplemented + + def test_gt_returns_not_implemented_for_wrong_type(self): + """__gt__ should return NotImplemented for non-LanguageProficiency.""" + result = LanguageProficiency.c2().__gt__("c2") + assert result is NotImplemented + + def test_ge_returns_not_implemented_for_wrong_type(self): + """__ge__ should return NotImplemented for non-LanguageProficiency.""" + result = LanguageProficiency.b2().__ge__(None) + assert result is NotImplemented + + +@pytest.mark.value_object +class TestLanguageProficiencyEnumOrdering: + """Test LanguageProficiencyEnum comparison operators.""" + + def test_enum_lt_returns_not_implemented_for_wrong_type(self): + """LanguageProficiencyEnum.__lt__() should return NotImplemented for non-enum.""" + result = LanguageProficiencyEnum.A1.__lt__("a1") + assert result is NotImplemented + + def test_enum_str(self): + """LanguageProficiencyEnum.__str__() should return the level value.""" + assert str(LanguageProficiencyEnum.A1) == "a1" + assert str(LanguageProficiencyEnum.C2) == "c2" + + +@pytest.mark.value_object +class TestProgrammingLanguageLevelComparisonWithWrongType: + """Test that comparison operators return NotImplemented for wrong types.""" + + def test_lt_returns_not_implemented_for_wrong_type(self): + """__lt__ should return NotImplemented for non-ProgrammingLanguageLevel.""" + result = ProgrammingLanguageLevel.basic().__lt__("basic") + assert result is NotImplemented + + def test_le_returns_not_implemented_for_wrong_type(self): + """__le__ should return NotImplemented for non-ProgrammingLanguageLevel.""" + result = ProgrammingLanguageLevel.intermediate().__le__(42) + assert result is NotImplemented + + def test_gt_returns_not_implemented_for_wrong_type(self): + """__gt__ should return NotImplemented for non-ProgrammingLanguageLevel.""" + result = ProgrammingLanguageLevel.advanced().__gt__("advanced") + assert result is NotImplemented + + def test_ge_returns_not_implemented_for_wrong_type(self): + """__ge__ should return NotImplemented for non-ProgrammingLanguageLevel.""" + result = ProgrammingLanguageLevel.expert().__ge__(None) + assert result is NotImplemented + + +@pytest.mark.value_object +class TestProgrammingLanguageLevelEnumOrdering: + """Test ProgrammingLanguageLevelEnum comparison operators.""" + + def test_enum_lt_returns_not_implemented_for_wrong_type(self): + """ProgrammingLanguageLevelEnum.__lt__() should return NotImplemented for non-enum.""" + result = ProgrammingLanguageLevelEnum.BASIC.__lt__("basic") + assert result is NotImplemented + + def test_enum_str(self): + """ProgrammingLanguageLevelEnum.__str__() should return the value.""" + assert str(ProgrammingLanguageLevelEnum.BASIC) == "basic" + assert str(ProgrammingLanguageLevelEnum.EXPERT) == "expert" diff --git a/tests/unit/domain/value_objects/test_skill_level_extended.py b/tests/unit/domain/value_objects/test_skill_level_extended.py new file mode 100644 index 0000000..9378cd3 --- /dev/null +++ b/tests/unit/domain/value_objects/test_skill_level_extended.py @@ -0,0 +1,238 @@ +""" +Extended tests for SkillLevel Value Object. + +Covers uncovered lines: try_create(), all_levels(), is_* predicates, +display_name(), is_at_least(), __repr__, __hash__, SkillLevelEnum ordering. +""" + +import pytest + +from app.domain.exceptions import InvalidSkillLevelError +from app.domain.value_objects.skill_level import SkillLevel, SkillLevelEnum + + +@pytest.mark.value_object +class TestSkillLevelTryCreate: + """Test try_create() factory method.""" + + def test_try_create_valid_returns_instance(self): + """try_create() with valid level returns SkillLevel.""" + result = SkillLevel.try_create("advanced") + + assert result is not None + assert result.to_string() == "advanced" + + def test_try_create_invalid_returns_none(self): + """try_create() with invalid level returns None.""" + result = SkillLevel.try_create("guru") + + assert result is None + + def test_try_create_empty_returns_none(self): + """try_create() with empty string returns None.""" + result = SkillLevel.try_create("") + + assert result is None + + +@pytest.mark.value_object +class TestSkillLevelAllLevels: + """Test all_levels() factory method.""" + + def test_all_levels_returns_four(self): + """Should return all 4 skill levels.""" + levels = SkillLevel.all_levels() + + assert len(levels) == 4 + + def test_all_levels_in_ascending_order(self): + """Levels should be in ascending order: basic < intermediate < advanced < expert.""" + levels = SkillLevel.all_levels() + + assert levels[0].is_basic() + assert levels[1].is_intermediate() + assert levels[2].is_advanced() + assert levels[3].is_expert() + + +@pytest.mark.value_object +class TestSkillLevelPredicates: + """Test is_basic(), is_intermediate(), is_advanced(), is_expert().""" + + def test_basic_predicate(self): + """is_basic() should return True only for basic level.""" + assert SkillLevel.basic().is_basic() is True + assert SkillLevel.intermediate().is_basic() is False + assert SkillLevel.advanced().is_basic() is False + assert SkillLevel.expert().is_basic() is False + + def test_intermediate_predicate(self): + """is_intermediate() should return True only for intermediate level.""" + assert SkillLevel.intermediate().is_intermediate() is True + assert SkillLevel.basic().is_intermediate() is False + assert SkillLevel.advanced().is_intermediate() is False + assert SkillLevel.expert().is_intermediate() is False + + def test_advanced_predicate(self): + """is_advanced() should return True only for advanced level.""" + assert SkillLevel.advanced().is_advanced() is True + assert SkillLevel.basic().is_advanced() is False + assert SkillLevel.intermediate().is_advanced() is False + assert SkillLevel.expert().is_advanced() is False + + def test_expert_predicate(self): + """is_expert() should return True only for expert level.""" + assert SkillLevel.expert().is_expert() is True + assert SkillLevel.basic().is_expert() is False + assert SkillLevel.intermediate().is_expert() is False + assert SkillLevel.advanced().is_expert() is False + + +@pytest.mark.value_object +class TestSkillLevelDisplayName: + """Test display_name() methods.""" + + @pytest.mark.parametrize( + ("level_str", "expected_display"), + [ + ("basic", "Basic"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ("expert", "Expert"), + ], + ) + def test_display_name_on_skill_level(self, level_str, expected_display): + """SkillLevel.display_name() should return capitalized level name.""" + level = SkillLevel.create(level_str) + + assert level.display_name() == expected_display + + @pytest.mark.parametrize( + ("enum_val", "expected"), + [ + (SkillLevelEnum.BASIC, "Basic"), + (SkillLevelEnum.INTERMEDIATE, "Intermediate"), + (SkillLevelEnum.ADVANCED, "Advanced"), + (SkillLevelEnum.EXPERT, "Expert"), + ], + ) + def test_display_name_on_enum(self, enum_val, expected): + """SkillLevelEnum.display_name() should return capitalized value.""" + assert enum_val.display_name() == expected + + +@pytest.mark.value_object +class TestSkillLevelIsAtLeast: + """Test is_at_least() method. + + NOTE: is_at_least() uses string comparison (lexicographic) on level values. + Alphabetical order of values: advanced < basic < expert < intermediate. + This test documents the actual implemented behaviour. + """ + + def test_same_level_is_at_least_itself(self): + """Any level should be at least itself.""" + assert SkillLevel.basic().is_at_least(SkillLevel.basic()) is True + assert SkillLevel.intermediate().is_at_least(SkillLevel.intermediate()) is True + assert SkillLevel.advanced().is_at_least(SkillLevel.advanced()) is True + assert SkillLevel.expert().is_at_least(SkillLevel.expert()) is True + + def test_is_at_least_uses_string_comparison(self): + """Verify behaviour: 'intermediate' >= 'expert' lexicographically.""" + # String comparison: "intermediate" > "expert" alphabetically + assert SkillLevel.intermediate().is_at_least(SkillLevel.expert()) is True + + def test_advanced_is_not_at_least_basic_lexicographically(self): + """'advanced' < 'basic' alphabetically, so advanced is NOT at least basic.""" + # String comparison: "advanced" < "basic" + assert SkillLevel.advanced().is_at_least(SkillLevel.basic()) is False + + def test_expert_is_at_least_advanced_lexicographically(self): + """'expert' > 'advanced' alphabetically.""" + assert SkillLevel.expert().is_at_least(SkillLevel.advanced()) is True + + +@pytest.mark.value_object +class TestSkillLevelHashAndRepr: + """Test __hash__ and __repr__ methods.""" + + def test_hash_equal_for_same_level(self): + """Two equal SkillLevels should have the same hash.""" + level1 = SkillLevel.create("advanced") + level2 = SkillLevel.create("advanced") + + assert hash(level1) == hash(level2) + + def test_hash_allows_use_in_set(self): + """SkillLevel should be usable in sets.""" + level1 = SkillLevel.basic() + level2 = SkillLevel.basic() + level3 = SkillLevel.expert() + + level_set = {level1, level2, level3} + + assert len(level_set) == 2 + + def test_repr_basic(self): + """__repr__ should return factory method representation.""" + assert repr(SkillLevel.basic()) == "SkillLevel.basic()" + + def test_repr_expert(self): + """__repr__ for expert should match expected format.""" + assert repr(SkillLevel.expert()) == "SkillLevel.expert()" + + def test_str(self): + """__str__ should return the level value string.""" + assert str(SkillLevel.intermediate()) == "intermediate" + + +@pytest.mark.value_object +class TestSkillLevelEnumOrdering: + """Test SkillLevelEnum comparison operators.""" + + def test_enum_str(self): + """SkillLevelEnum.__str__() should return the value.""" + assert str(SkillLevelEnum.BASIC) == "basic" + assert str(SkillLevelEnum.EXPERT) == "expert" + + def test_enum_lt_ordering(self): + """SkillLevelEnum should support < comparison.""" + assert SkillLevelEnum.BASIC < SkillLevelEnum.INTERMEDIATE + assert SkillLevelEnum.INTERMEDIATE < SkillLevelEnum.ADVANCED + assert SkillLevelEnum.ADVANCED < SkillLevelEnum.EXPERT + + def test_enum_lt_returns_not_implemented_for_wrong_type(self): + """SkillLevelEnum.__lt__() should return NotImplemented for non-enum.""" + result = SkillLevelEnum.BASIC.__lt__("basic") + + assert result is NotImplemented + + +@pytest.mark.value_object +class TestSkillLevelComparisonWithWrongType: + """Test comparison operators with non-SkillLevel operands.""" + + def test_lt_returns_not_implemented_for_wrong_type(self): + """__lt__ should return NotImplemented for non-SkillLevel.""" + result = SkillLevel.basic().__lt__("basic") + assert result is NotImplemented + + def test_le_returns_not_implemented_for_wrong_type(self): + """__le__ should return NotImplemented for non-SkillLevel.""" + result = SkillLevel.basic().__le__("basic") + assert result is NotImplemented + + def test_gt_returns_not_implemented_for_wrong_type(self): + """__gt__ should return NotImplemented for non-SkillLevel.""" + result = SkillLevel.basic().__gt__("basic") + assert result is NotImplemented + + def test_ge_returns_not_implemented_for_wrong_type(self): + """__ge__ should return NotImplemented for non-SkillLevel.""" + result = SkillLevel.basic().__ge__("basic") + assert result is NotImplemented + + def test_eq_returns_false_for_non_skill_level(self): + """__eq__ should return False for non-SkillLevel objects.""" + assert SkillLevel.basic().__eq__("basic") is False + assert SkillLevel.expert().__eq__(None) is False From 5c64df6ded83c20947264b05054551a456046b66 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 16:04:09 +0200 Subject: [PATCH 4/6] fix(test): fixed format + lint + sort imports --- tests/unit/domain/entities/test_profile_extended.py | 1 - tests/unit/domain/entities/test_skill_extended.py | 13 +++++++++---- .../value_objects/test_date_range_extended.py | 1 - .../domain/value_objects/test_email_extended.py | 1 - .../value_objects/test_skill_level_extended.py | 1 - 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/unit/domain/entities/test_profile_extended.py b/tests/unit/domain/entities/test_profile_extended.py index ba392d2..80c03cc 100644 --- a/tests/unit/domain/entities/test_profile_extended.py +++ b/tests/unit/domain/entities/test_profile_extended.py @@ -109,7 +109,6 @@ def test_update_avatar_with_invalid_url_raises_error(self): def test_update_avatar_updates_timestamp(self): """update_avatar() should update the updated_at timestamp.""" - from datetime import datetime profile = Profile.create(name="John", headline="Engineer") before = profile.updated_at diff --git a/tests/unit/domain/entities/test_skill_extended.py b/tests/unit/domain/entities/test_skill_extended.py index 3087690..7b47c37 100644 --- a/tests/unit/domain/entities/test_skill_extended.py +++ b/tests/unit/domain/entities/test_skill_extended.py @@ -10,7 +10,6 @@ from app.domain.entities.skill import Skill from app.domain.exceptions import ( EmptyFieldError, - InvalidLengthError, InvalidNameError, InvalidOrderIndexError, InvalidSkillLevelError, @@ -23,19 +22,25 @@ class TestSkillLevelNormalization: def test_whitespace_level_normalized_to_none(self, profile_id): """Level with only whitespace should be stored as None.""" - skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level=" ") + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level=" " + ) assert skill.level is None def test_level_normalized_to_lowercase(self, profile_id): """Level string should be normalized to lowercase.""" - skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level="ADVANCED") + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level="ADVANCED" + ) assert skill.level == "advanced" def test_none_level_stays_none(self, profile_id): """None level should remain None.""" - skill = Skill.create(profile_id=profile_id, name="Python", order_index=0, level=None) + skill = Skill.create( + profile_id=profile_id, name="Python", order_index=0, level=None + ) assert skill.level is None diff --git a/tests/unit/domain/value_objects/test_date_range_extended.py b/tests/unit/domain/value_objects/test_date_range_extended.py index f3035f5..16054f0 100644 --- a/tests/unit/domain/value_objects/test_date_range_extended.py +++ b/tests/unit/domain/value_objects/test_date_range_extended.py @@ -268,7 +268,6 @@ def test_equal_start_and_end_raises_error(self): def test_none_start_date_raises_empty_field_error(self): """None start_date should raise EmptyFieldError.""" - from app.domain.exceptions import EmptyFieldError with pytest.raises(EmptyFieldError): DateRange(start_date=None) # type: ignore[arg-type] diff --git a/tests/unit/domain/value_objects/test_email_extended.py b/tests/unit/domain/value_objects/test_email_extended.py index b7b28ee..c08adbc 100644 --- a/tests/unit/domain/value_objects/test_email_extended.py +++ b/tests/unit/domain/value_objects/test_email_extended.py @@ -7,7 +7,6 @@ import pytest -from app.domain.exceptions import EmptyFieldError, InvalidEmailError from app.domain.value_objects.email import Email diff --git a/tests/unit/domain/value_objects/test_skill_level_extended.py b/tests/unit/domain/value_objects/test_skill_level_extended.py index 9378cd3..010efef 100644 --- a/tests/unit/domain/value_objects/test_skill_level_extended.py +++ b/tests/unit/domain/value_objects/test_skill_level_extended.py @@ -7,7 +7,6 @@ import pytest -from app.domain.exceptions import InvalidSkillLevelError from app.domain.value_objects.skill_level import SkillLevel, SkillLevelEnum From aa11c877c691e4609fb064dfd1d218abed06d4ae Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 18:38:48 +0200 Subject: [PATCH 5/6] test: reach 100% coverage on contact_message_repository --- .../test_contact_message_repository.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/unit/infrastructure/repositories/test_contact_message_repository.py b/tests/unit/infrastructure/repositories/test_contact_message_repository.py index f6bec6b..5c861bd 100644 --- a/tests/unit/infrastructure/repositories/test_contact_message_repository.py +++ b/tests/unit/infrastructure/repositories/test_contact_message_repository.py @@ -154,3 +154,120 @@ async def test_mark_as_replied_not_found(self, repo, collection): result = await repo.mark_as_replied("nonexistent") assert result is False + + +class TestContactMessageUpdate: + @pytest.mark.unit + @pytest.mark.asyncio + async def test_update_calls_replace_one_without_id(self, repo, collection): + """update() should strip _id from the doc and call replace_one.""" + entity = MagicMock() + entity.id = "msg-upd-1" + + result = await repo.update(entity) + + collection.replace_one.assert_called_once() + call_args = collection.replace_one.call_args + filter_arg = call_args[0][0] + replacement_doc = call_args[0][1] + assert filter_arg == {"_id": "msg-upd-1"} + assert "_id" not in replacement_doc + assert result is entity + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_update_returns_same_entity(self, repo, collection): + """update() must return the exact entity that was passed in.""" + entity = MagicMock() + entity.id = "msg-upd-2" + + returned = await repo.update(entity) + + assert returned is entity + + +class TestContactMessageCountAndExists: + @pytest.mark.unit + @pytest.mark.asyncio + async def test_count_without_filters_passes_empty_dict(self, repo, collection): + """count(None) should call count_documents with {}.""" + collection.count_documents = AsyncMock(return_value=5) + + result = await repo.count(filters=None) + + collection.count_documents.assert_called_once_with({}) + assert result == 5 + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_count_with_filters(self, repo, collection): + """count() with an explicit filter dict should forward it unchanged.""" + collection.count_documents = AsyncMock(return_value=3) + + result = await repo.count(filters={"status": "pending"}) + + collection.count_documents.assert_called_once_with({"status": "pending"}) + assert result == 3 + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_exists_returns_true_when_count_positive(self, repo, collection): + """exists() should return True when count_documents > 0.""" + collection.count_documents = AsyncMock(return_value=1) + + result = await repo.exists("msg-123") + + collection.count_documents.assert_called_once_with({"_id": "msg-123"}) + assert result is True + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_exists_returns_false_when_count_zero(self, repo, collection): + """exists() should return False when count_documents == 0.""" + collection.count_documents = AsyncMock(return_value=0) + + result = await repo.exists("ghost-id") + + assert result is False + + +class TestContactMessageFindBy: + @pytest.mark.unit + @pytest.mark.asyncio + async def test_find_by_returns_matching_entities(self, repo, collection): + """find_by() should pass kwargs as a filter and return mapped entities.""" + from .conftest import make_contact_message_doc + + docs = [ + make_contact_message_doc(_id="m1", status="read"), + make_contact_message_doc(_id="m2", status="read"), + ] + cursor = collection.find.return_value + cursor.to_list = AsyncMock(return_value=docs) + + result = await repo.find_by(status="read") + + collection.find.assert_called_once_with({"status": "read"}) + assert len(result) == 2 + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_find_by_returns_empty_list_when_no_matches(self, repo, collection): + """find_by() should return an empty list when the cursor yields nothing.""" + cursor = collection.find.return_value + cursor.to_list = AsyncMock(return_value=[]) + + result = await repo.find_by(status="nonexistent") + + assert result == [] + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_find_by_multiple_filters(self, repo, collection): + """find_by() should forward all kwargs as a combined filter.""" + cursor = collection.find.return_value + cursor.to_list = AsyncMock(return_value=[]) + + await repo.find_by(status="pending", name="Jane Doe") + + collection.find.assert_called_once_with({"status": "pending", "name": "Jane Doe"}) From 012945e6ecf93eb3724ebb4de893db24b260c4f9 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Tue, 5 May 2026 18:41:38 +0200 Subject: [PATCH 6/6] fix(test): fixed format + lint + sort imports --- .../repositories/test_contact_message_repository.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/infrastructure/repositories/test_contact_message_repository.py b/tests/unit/infrastructure/repositories/test_contact_message_repository.py index 5c861bd..09ff90f 100644 --- a/tests/unit/infrastructure/repositories/test_contact_message_repository.py +++ b/tests/unit/infrastructure/repositories/test_contact_message_repository.py @@ -270,4 +270,6 @@ async def test_find_by_multiple_filters(self, repo, collection): await repo.find_by(status="pending", name="Jane Doe") - collection.find.assert_called_once_with({"status": "pending", "name": "Jane Doe"}) + collection.find.assert_called_once_with( + {"status": "pending", "name": "Jane Doe"} + )