Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/api/schemas/projects_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions app/api/v1/routers/projects_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)
Expand All @@ -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,
)
)
Expand Down
4 changes: 4 additions & 0 deletions app/application/dto/project_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/application/use_cases/project/add_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
7 changes: 6 additions & 1 deletion app/application/use_cases/project/edit_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions app/domain/entities/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
"""
Expand All @@ -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:
Expand All @@ -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 [],
)

Expand Down Expand Up @@ -160,20 +165,25 @@ def update_urls(
self,
live_url: str | None = None,
repo_url: str | None = None,
image_url: str | None = None,
) -> None:
"""
Update project 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

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()
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions app/infrastructure/mappers/project_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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
2 changes: 2 additions & 0 deletions tests/integration/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/unit/application/dto/test_project_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/domain/entities/test_project_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading