diff --git a/app/api/schemas/projects_schema.py b/app/api/schemas/projects_schema.py index beec50c..229c743 100644 --- a/app/api/schemas/projects_schema.py +++ b/app/api/schemas/projects_schema.py @@ -38,6 +38,9 @@ class ProjectBase(BaseModel): repo_url: str | None = Field( None, description="Enlace al repositorio (GitHub, GitLab, etc.)" ) + image_url: str | None = Field( + None, description="URL de imagen o screenshot del proyecto (opcional)" + ) technologies: list[str] = Field( default_factory=list, description="Lista de tecnologías utilizadas" ) @@ -81,6 +84,7 @@ class ProjectUpdate(BaseModel): technologies: list[str] | None = None live_url: str | None = None repo_url: str | None = None + image_url: str | None = None order_index: int | None = Field(None, ge=0) @field_validator("end_date") diff --git a/app/api/v1/routers/projects_router.py b/app/api/v1/routers/projects_router.py index 0c5f92d..8ce6d04 100644 --- a/app/api/v1/routers/projects_router.py +++ b/app/api/v1/routers/projects_router.py @@ -84,6 +84,7 @@ async def create_project( end_date=project_data.end_date, live_url=project_data.live_url, repo_url=project_data.repo_url, + image_url=project_data.image_url, technologies=project_data.technologies, ) ) @@ -110,6 +111,7 @@ async def update_project( end_date=project_data.end_date, live_url=project_data.live_url, repo_url=project_data.repo_url, + image_url=project_data.image_url, technologies=project_data.technologies, ) ) diff --git a/app/application/dto/project_dto.py b/app/application/dto/project_dto.py index 15e6fee..2424538 100644 --- a/app/application/dto/project_dto.py +++ b/app/application/dto/project_dto.py @@ -20,6 +20,7 @@ class AddProjectRequest: end_date: datetime | None = None live_url: str | None = None repo_url: str | None = None + image_url: str | None = None technologies: list[str] | None = None @@ -34,6 +35,7 @@ class EditProjectRequest: end_date: datetime | None = None live_url: str | None = None repo_url: str | None = None + image_url: str | None = None technologies: list[str] | None = None @@ -65,6 +67,7 @@ class ProjectResponse: end_date: datetime | None live_url: str | None repo_url: str | None + image_url: str | None technologies: list[str] created_at: datetime updated_at: datetime @@ -83,6 +86,7 @@ def from_entity(cls, entity) -> "ProjectResponse": end_date=entity.end_date, live_url=entity.live_url, repo_url=entity.repo_url, + image_url=entity.image_url, technologies=entity.technologies, created_at=entity.created_at, updated_at=entity.updated_at, diff --git a/app/application/use_cases/project/add_project.py b/app/application/use_cases/project/add_project.py index abc7fe4..4195940 100644 --- a/app/application/use_cases/project/add_project.py +++ b/app/application/use_cases/project/add_project.py @@ -71,6 +71,7 @@ async def execute(self, request: AddProjectRequest) -> ProjectResponse: end_date=request.end_date, live_url=request.live_url, repo_url=request.repo_url, + image_url=request.image_url, technologies=request.technologies, ) diff --git a/app/application/use_cases/project/edit_project.py b/app/application/use_cases/project/edit_project.py index bf9b8a9..cc9fc0a 100644 --- a/app/application/use_cases/project/edit_project.py +++ b/app/application/use_cases/project/edit_project.py @@ -65,10 +65,15 @@ async def execute(self, request: EditProjectRequest) -> ProjectResponse: ) # Update URLs if provided - if request.live_url is not None or request.repo_url is not None: + if ( + request.live_url is not None + or request.repo_url is not None + or request.image_url is not None + ): project.update_urls( live_url=request.live_url, repo_url=request.repo_url, + image_url=request.image_url, ) # Update technologies if provided diff --git a/app/domain/entities/project.py b/app/domain/entities/project.py index 56009dc..e18d3e1 100644 --- a/app/domain/entities/project.py +++ b/app/domain/entities/project.py @@ -13,6 +13,7 @@ - RB-PR07: technologies is optional array (max 20 items, each max 50 chars) - RB-PR08: orderIndex is required and must be unique per profile - RB-PR09: If no URLs, description must be sufficiently detailed (min 100 chars) +- RB-PR10: imageUrl is optional, must be a valid URL if provided """ from dataclasses import dataclass, field @@ -48,6 +49,7 @@ class Project: end_date: datetime | None = None live_url: str | None = None repo_url: str | None = None + image_url: str | None = None technologies: list[str] = field(default_factory=list) created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -90,6 +92,7 @@ def create( end_date: datetime | None = None, live_url: str | None = None, repo_url: str | None = None, + image_url: str | None = None, technologies: list[str] | None = None, ) -> "Project": """ @@ -104,6 +107,7 @@ def create( end_date: When the project ended (None if ongoing) live_url: URL to live project repo_url: URL to repository + image_url: URL to project screenshot/image technologies: List of technologies used Returns: @@ -119,6 +123,7 @@ def create( end_date=end_date, live_url=live_url, repo_url=repo_url, + image_url=image_url, technologies=technologies or [], ) @@ -160,6 +165,7 @@ def update_urls( self, live_url: str | None = None, repo_url: str | None = None, + image_url: str | None = None, ) -> None: """ Update project URLs. @@ -167,6 +173,7 @@ def update_urls( Args: live_url: New live URL (optional) repo_url: New repository URL (optional) + image_url: New image/screenshot URL (optional) """ if live_url is not None: self.live_url = live_url @@ -174,6 +181,9 @@ def update_urls( if repo_url is not None: self.repo_url = repo_url + if image_url is not None: + self.image_url = image_url + self._validate_urls() self._validate_description_sufficiency() self._mark_as_updated() @@ -290,6 +300,12 @@ def _validate_urls(self) -> None: elif not self.URL_PATTERN.match(self.repo_url): raise InvalidURLError(self.repo_url) + if self.image_url is not None: + if self.image_url.strip() == "": + self.image_url = None + elif not self.URL_PATTERN.match(self.image_url): + raise InvalidURLError(self.image_url) + def _validate_technologies(self) -> None: """Validate technologies list.""" if len(self.technologies) > self.MAX_TECHNOLOGIES: diff --git a/app/infrastructure/mappers/project_mapper.py b/app/infrastructure/mappers/project_mapper.py index 77eb231..01c01a4 100644 --- a/app/infrastructure/mappers/project_mapper.py +++ b/app/infrastructure/mappers/project_mapper.py @@ -17,6 +17,7 @@ def to_domain(self, persistence_model: dict[str, Any]) -> Project: end_date=persistence_model.get("end_date"), live_url=persistence_model.get("live_url"), repo_url=persistence_model.get("repo_url"), + image_url=persistence_model.get("image_url"), technologies=persistence_model.get("technologies", []), created_at=persistence_model["created_at"], updated_at=persistence_model["updated_at"], @@ -40,4 +41,6 @@ def to_persistence(self, domain_entity: Project) -> dict[str, Any]: doc["live_url"] = domain_entity.live_url if domain_entity.repo_url is not None: doc["repo_url"] = domain_entity.repo_url + if domain_entity.image_url is not None: + doc["image_url"] = domain_entity.image_url return doc diff --git a/tests/integration/api/conftest.py b/tests/integration/api/conftest.py index 3abc596..d490608 100644 --- a/tests/integration/api/conftest.py +++ b/tests/integration/api/conftest.py @@ -263,6 +263,7 @@ end_date=None, live_url="https://example.com", repo_url="https://github.com/example/portfolio", + image_url=None, technologies=["Python", "FastAPI", "React"], created_at=NOW, updated_at=NOW, @@ -278,6 +279,7 @@ end_date=datetime(2023, 12, 31), live_url=None, repo_url=None, + image_url=None, technologies=["Python", "Django"], created_at=NOW, updated_at=NOW, diff --git a/tests/unit/api/conftest.py b/tests/unit/api/conftest.py index ad3c878..a69b3b6 100644 --- a/tests/unit/api/conftest.py +++ b/tests/unit/api/conftest.py @@ -259,6 +259,7 @@ end_date=None, live_url="https://example.com", repo_url="https://github.com/example/portfolio", + image_url=None, technologies=["Python", "FastAPI", "React"], created_at=NOW, updated_at=NOW, @@ -274,6 +275,7 @@ end_date=datetime(2023, 12, 31), live_url=None, repo_url=None, + image_url=None, technologies=["Python", "Django"], created_at=NOW, updated_at=NOW, diff --git a/tests/unit/application/dto/test_project_dto.py b/tests/unit/application/dto/test_project_dto.py index 97f1a15..0ea5bb0 100644 --- a/tests/unit/application/dto/test_project_dto.py +++ b/tests/unit/application/dto/test_project_dto.py @@ -30,6 +30,7 @@ def _make_project_entity(**overrides): "end_date": DATE_END, "live_url": "https://azfe.dev", "repo_url": "https://github.com/azfe/portfolio", + "image_url": None, "technologies": ["Python", "FastAPI", "MongoDB"], } # Separate the special _is_ongoing control key before passing to make_entity diff --git a/tests/unit/domain/entities/test_project_extended.py b/tests/unit/domain/entities/test_project_extended.py index 87a7229..2aeaf84 100644 --- a/tests/unit/domain/entities/test_project_extended.py +++ b/tests/unit/domain/entities/test_project_extended.py @@ -237,6 +237,76 @@ def test_update_urls_updates_timestamp(self, profile_id): assert project.updated_at >= before +@pytest.mark.entity +class TestProjectImageURL: + """Tests for image_url field — RB-PR10.""" + + def test_create_with_valid_image_url(self, profile_id): + """Valid image_url is accepted at creation.""" + project = _make_project( + profile_id, + image_url="https://res.cloudinary.com/demo/image/upload/sample.jpg", + ) + + assert ( + project.image_url + == "https://res.cloudinary.com/demo/image/upload/sample.jpg" + ) + + def test_create_with_invalid_image_url_raises(self, profile_id): + """Invalid image_url at creation should raise InvalidURLError.""" + with pytest.raises(InvalidURLError): + _make_project(profile_id, image_url="not-a-url") + + def test_create_with_empty_image_url_normalized_to_none(self, profile_id): + """Empty image_url string at creation should be normalized to None.""" + project = _make_project(profile_id, image_url="") + + assert project.image_url is None + + def test_create_with_whitespace_image_url_normalized_to_none(self, profile_id): + """Whitespace image_url at creation should be normalized to None.""" + project = _make_project(profile_id, image_url=" ") + + assert project.image_url is None + + def test_update_urls_with_valid_image_url(self, profile_id): + """update_urls() with a valid image_url should update the field.""" + project = _make_project(profile_id) + + project.update_urls(image_url="https://example.com/screenshot.png") + + assert project.image_url == "https://example.com/screenshot.png" + + def test_update_urls_image_url_only(self, profile_id): + """update_urls() with only image_url (no live_url or repo_url) should update correctly.""" + project = _make_project(profile_id) + + project.update_urls(image_url="https://cdn.example.com/project.jpg") + + assert project.image_url == "https://cdn.example.com/project.jpg" + assert project.live_url is None + assert project.repo_url is None + + def test_update_urls_invalid_image_url_raises(self, profile_id): + """Invalid image_url in update_urls() should raise InvalidURLError.""" + project = _make_project(profile_id) + + with pytest.raises(InvalidURLError): + project.update_urls(image_url="ftp://invalid-scheme") + + def test_update_urls_empty_image_url_normalized_to_none(self, profile_id): + """Passing empty string as image_url in update_urls() should normalize to None.""" + project = _make_project( + profile_id, + image_url="https://example.com/screenshot.png", + ) + + project.update_urls(image_url="") + + assert project.image_url is None + + @pytest.mark.entity class TestProjectRepr: """Test __repr__ method."""