diff --git a/app/application/__init__.py b/app/application/__init__.py index eb4a716..5452052 100644 --- a/app/application/__init__.py +++ b/app/application/__init__.py @@ -12,6 +12,7 @@ from app.application.use_cases import ( GetPostUseCase, ListPostsUseCase, PublishPostUseCase, + TogglePostLikeUseCase, UpdatePostUseCase, ) @@ -26,4 +27,5 @@ __all__ = [ "DeletePostUseCase", "ListPostsUseCase", "PublishPostUseCase", + "TogglePostLikeUseCase", ] diff --git a/app/application/dtos/post.py b/app/application/dtos/post.py index 0479eee..344f81e 100644 --- a/app/application/dtos/post.py +++ b/app/application/dtos/post.py @@ -100,3 +100,4 @@ class PostResponseDTO: tags: list[str] created_at: datetime updated_at: datetime + like_count: int = 0 diff --git a/app/application/use_cases/__init__.py b/app/application/use_cases/__init__.py index 12d862d..e35bd5b 100644 --- a/app/application/use_cases/__init__.py +++ b/app/application/use_cases/__init__.py @@ -9,6 +9,7 @@ from app.application.use_cases.delete_post import DeletePostUseCase from app.application.use_cases.get_post import GetPostUseCase from app.application.use_cases.list_posts import ListPostsUseCase from app.application.use_cases.publish_post import PublishPostUseCase +from app.application.use_cases.toggle_like import TogglePostLikeUseCase from app.application.use_cases.update_post import UpdatePostUseCase __all__ = [ @@ -18,4 +19,5 @@ __all__ = [ "DeletePostUseCase", "ListPostsUseCase", "PublishPostUseCase", + "TogglePostLikeUseCase", ] diff --git a/app/application/use_cases/toggle_like.py b/app/application/use_cases/toggle_like.py new file mode 100644 index 0000000..36ef679 --- /dev/null +++ b/app/application/use_cases/toggle_like.py @@ -0,0 +1,102 @@ +"""Toggle post like use case. + +This module implements the use case for toggling likes on blog posts. +If the user already liked the post, the like is removed (unlike). +If not, a new like is added. +""" + +from uuid import UUID + +from app.application.dtos.post import PostResponseDTO +from app.application.interfaces import TransactionManager +from app.domain.entities import Post +from app.domain.entities.like import PostLike +from app.domain.exceptions import NotFoundException +from app.domain.repositories import PostRepository + + +class TogglePostLikeUseCase: + """Use case for toggling a like on a blog post. + + Handles like/unlike toggle logic. If the user or device has already + liked the post, the like is removed. Otherwise, a new like is created. + + Attributes: + _post_repo: Repository for post and like data access. + _tx_manager: Transaction manager for commit control. + + Example: + >>> use_case = TogglePostLikeUseCase(post_repo, tx_manager) + >>> result = await use_case.execute("my-post-slug", "user-123") + """ + + def __init__( + self, + post_repo: PostRepository, + tx_manager: TransactionManager, + ) -> None: + """Initialize use case with dependencies. + + Args: + post_repo: Repository for post and like operations. + tx_manager: Transaction manager instance. + """ + self._post_repo = post_repo + self._tx_manager = tx_manager + + async def execute(self, post_id: UUID, liked_by: str) -> PostResponseDTO: + """Toggle like on a post. + + If the user/device already liked the post, remove the like. + Otherwise, add a new like. + + Args: + post_id: UUID of the post to toggle like on. + liked_by: User ID or device identifier. + + Returns: + PostResponseDTO with updated like_count. + + Raises: + NotFoundException: If post with given ID does not exist. + """ + post = await self._post_repo.get_by_id(post_id) + if not post: + raise NotFoundException(f"Post with id '{post_id}' not found") + + existing_like = await self._post_repo.get_like(post_id, liked_by) + + if existing_like: + await self._post_repo.remove_like(post_id, liked_by) + post.like_count = max(0, post.like_count - 1) + else: + new_like = PostLike(post_id=post_id, liked_by=liked_by) + await self._post_repo.add_like(new_like) + post.like_count += 1 + + await self._post_repo.update(post) + await self._tx_manager.commit() + + return self._map_to_dto(post) + + def _map_to_dto(self, post: Post) -> PostResponseDTO: + """Map domain entity to response DTO. + + Args: + post: Domain post entity. + + Returns: + PostResponseDTO with all post attributes including like_count. + """ + return PostResponseDTO( + id=post.id, + title=post.title.value, + content=post.content.value, + slug=post.slug.value, + author_id=post.author_id, + published=post.published, + like_count=post.like_count, + tags=post.tags.copy(), + created_at=post.created_at, + updated_at=post.updated_at, + ) diff --git a/app/domain/__init__.py b/app/domain/__init__.py index 3c1e050..867643d 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -4,7 +4,7 @@ This module re-exports all domain layer components including entities, value objects, repositories, and exceptions. """ -from app.domain.entities import BaseEntity, Post +from app.domain.entities import BaseEntity, Post, PostLike from app.domain.exceptions import ( AlreadyExistsException, DomainException, @@ -19,6 +19,7 @@ from app.domain.value_objects import Content, Slug, Title, ValueObject __all__ = [ "BaseEntity", "Post", + "PostLike", "ValueObject", "Title", "Content", diff --git a/app/domain/entities/__init__.py b/app/domain/entities/__init__.py index 2e27852..00bb7f0 100644 --- a/app/domain/entities/__init__.py +++ b/app/domain/entities/__init__.py @@ -5,6 +5,7 @@ core business objects with identity. """ from app.domain.entities.base import BaseEntity +from app.domain.entities.like import PostLike from app.domain.entities.post import Post -__all__ = ["BaseEntity", "Post"] +__all__ = ["BaseEntity", "Post", "PostLike"] diff --git a/app/domain/entities/like.py b/app/domain/entities/like.py new file mode 100644 index 0000000..6c2679e --- /dev/null +++ b/app/domain/entities/like.py @@ -0,0 +1,40 @@ +"""Domain entity for PostLike. + +This module defines the PostLike entity that tracks which users +or devices have liked which posts. +""" + +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from app.domain.entities.base import BaseEntity + + +@dataclass(kw_only=True) +class PostLike(BaseEntity): + """Post like domain entity. + + Tracks a like on a blog post by a user or device. + Each like is uniquely identified by its entity ID. + + Attributes: + post_id: UUID of the liked post. + liked_by: Identifier of the user or device that liked. + """ + + post_id: UUID + liked_by: str + + def to_dict(self) -> dict[str, Any]: + """Convert entity to dictionary. + + Returns: + Dictionary with all PostLike attributes. + """ + return { + "id": str(self.id), + "post_id": str(self.post_id), + "liked_by": self.liked_by, + "created_at": self.created_at.isoformat(), + } diff --git a/app/domain/entities/post.py b/app/domain/entities/post.py index 7cffe24..84c090e 100644 --- a/app/domain/entities/post.py +++ b/app/domain/entities/post.py @@ -44,6 +44,7 @@ class Post(BaseEntity): slug: Slug author_id: str published: bool = False + like_count: int = 0 tags: list[str] = field(default_factory=list) def publish(self) -> None: @@ -114,6 +115,7 @@ class Post(BaseEntity): "slug": self.slug.value, "author_id": self.author_id, "published": self.published, + "like_count": self.like_count, "tags": self.tags.copy(), "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), diff --git a/app/domain/repositories/post.py b/app/domain/repositories/post.py index 79414f0..e8744f6 100644 --- a/app/domain/repositories/post.py +++ b/app/domain/repositories/post.py @@ -1,11 +1,14 @@ """Post repository interface. This module extends the base repository interface with post-specific -query methods including slug lookup, author filtering, and search. +query methods including slug lookup, author filtering, search, and +like management. """ from abc import abstractmethod +from uuid import UUID +from app.domain.entities.like import PostLike from app.domain.entities.post import Post from app.domain.repositories.base import Repository @@ -101,6 +104,38 @@ class PostRepository(Repository[Post]): """ ... + @abstractmethod + async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None: + """Get a like by post and user/device. + + Args: + post_id: UUID of the post. + liked_by: User ID or device ID. + + Returns: + PostLike if found, None otherwise. + """ + ... + + @abstractmethod + async def add_like(self, like: PostLike) -> None: + """Add a new like. + + Args: + like: PostLike entity to add. + """ + ... + + @abstractmethod + async def remove_like(self, post_id: UUID, liked_by: str) -> None: + """Remove a like by post and user/device. + + Args: + post_id: UUID of the post. + liked_by: User ID or device ID. + """ + ... + @abstractmethod async def search( self, diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py index 8022df5..c35b6f7 100644 --- a/app/infrastructure/database/models.py +++ b/app/infrastructure/database/models.py @@ -7,8 +7,8 @@ Models are used by repositories for data persistence. from datetime import UTC, datetime from uuid import uuid4 -from sqlalchemy import JSON, Boolean, DateTime, String, Text -from sqlalchemy.orm import Mapped, declarative_base, mapped_column +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship Base = declarative_base() @@ -42,6 +42,10 @@ class PostORM(Base): # type: ignore[valid-type,misc] slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True) author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + like_count: Mapped[int] = mapped_column(default=0, nullable=False) + likes: Mapped[list["PostLikeORM"]] = relationship( + back_populates="post", cascade="all, delete-orphan" + ) tags: Mapped[list[str]] = mapped_column(JSON, default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -54,3 +58,32 @@ class PostORM(Base): # type: ignore[valid-type,misc] onupdate=lambda: datetime.now(UTC), nullable=False, ) + + +class PostLikeORM(Base): # type: ignore[valid-type,misc] + """SQLAlchemy model for PostLike. + + Database table representation of post likes. + Maps to the 'post_likes' table tracking which users/devices liked which posts. + + Attributes: + id: Primary key as UUID string. + post_id: Foreign key to the liked post. + liked_by: User ID or device identifier. + created_at: Creation timestamp. + """ + + __tablename__ = "post_likes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + post_id: Mapped[str] = mapped_column( + String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True + ) + liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + + post: Mapped[PostORM] = relationship(back_populates="likes") diff --git a/app/infrastructure/di/providers.py b/app/infrastructure/di/providers.py index 844a762..6970db4 100644 --- a/app/infrastructure/di/providers.py +++ b/app/infrastructure/di/providers.py @@ -15,6 +15,7 @@ from app.application import ( GetPostUseCase, ListPostsUseCase, PublishPostUseCase, + TogglePostLikeUseCase, UpdatePostUseCase, ) from app.application.interfaces import TransactionManager @@ -236,6 +237,26 @@ class UseCaseProvider(Provider): tx_manager=tx_manager, ) + @provide(scope=Scope.REQUEST) + def get_toggle_like_use_case( + self, + post_repo: PostRepository, + tx_manager: TransactionManager, + ) -> TogglePostLikeUseCase: + """Provide TogglePostLikeUseCase. + + Args: + post_repo: Post repository dependency. + tx_manager: Transaction manager dependency. + + Returns: + Configured TogglePostLikeUseCase instance. + """ + return TogglePostLikeUseCase( + post_repo=post_repo, + tx_manager=tx_manager, + ) + class KeycloakProvider(Provider): """Provider for Keycloak authentication client. diff --git a/app/infrastructure/repositories/post.py b/app/infrastructure/repositories/post.py index 8b0df02..83a0d23 100644 --- a/app/infrastructure/repositories/post.py +++ b/app/infrastructure/repositories/post.py @@ -10,9 +10,10 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.domain.entities import Post +from app.domain.entities.like import PostLike from app.domain.repositories import PostRepository from app.domain.value_objects import Content, Slug, Title -from app.infrastructure.database.models import PostORM +from app.infrastructure.database.models import PostLikeORM, PostORM class SQLAlchemyPostRepository(PostRepository): @@ -53,6 +54,7 @@ class SQLAlchemyPostRepository(PostRepository): slug=Slug(orm.slug), author_id=orm.author_id, published=orm.published, + like_count=orm.like_count, tags=orm.tags or [], created_at=orm.created_at, updated_at=orm.updated_at, @@ -74,6 +76,7 @@ class SQLAlchemyPostRepository(PostRepository): slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags, created_at=post.created_at, updated_at=post.updated_at, @@ -124,6 +127,7 @@ class SQLAlchemyPostRepository(PostRepository): orm.content = entity.content.value orm.slug = entity.slug.value orm.published = entity.published + orm.like_count = entity.like_count orm.tags = entity.tags orm.updated_at = entity.updated_at @@ -284,3 +288,60 @@ class SQLAlchemyPostRepository(PostRepository): result = await self._session.execute(stmt) orms = result.scalars().all() return [self._to_domain(orm) for orm in orms] + + async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None: + """Get a like by post and user/device. + + Args: + post_id: UUID of the post. + liked_by: User ID or device ID. + + Returns: + PostLike if found, None otherwise. + """ + result = await self._session.execute( + select(PostLikeORM).where( + PostLikeORM.post_id == str(post_id), + PostLikeORM.liked_by == liked_by, + ) + ) + orm = result.scalar_one_or_none() + if not orm: + return None + return PostLike( + id=UUID(orm.id), + post_id=UUID(orm.post_id), + liked_by=orm.liked_by, + created_at=orm.created_at, + ) + + async def add_like(self, like: PostLike) -> None: + """Add a new like. + + Args: + like: PostLike entity to add. + """ + orm = PostLikeORM( + id=str(like.id), + post_id=str(like.post_id), + liked_by=like.liked_by, + created_at=like.created_at, + ) + self._session.add(orm) + + async def remove_like(self, post_id: UUID, liked_by: str) -> None: + """Remove a like by post and user/device. + + Args: + post_id: UUID of the post. + liked_by: User ID or device ID. + """ + result = await self._session.execute( + select(PostLikeORM).where( + PostLikeORM.post_id == str(post_id), + PostLikeORM.liked_by == liked_by, + ) + ) + orm = result.scalar_one_or_none() + if orm: + await self._session.delete(orm) diff --git a/app/presentation/api/deps.py b/app/presentation/api/deps.py index a6aff70..dd288e7 100644 --- a/app/presentation/api/deps.py +++ b/app/presentation/api/deps.py @@ -16,6 +16,7 @@ from app.application import ( GetPostUseCase, ListPostsUseCase, PublishPostUseCase, + TogglePostLikeUseCase, UpdatePostUseCase, ) from app.domain.exceptions import ForbiddenException, UnauthorizedException @@ -28,6 +29,7 @@ UpdatePostDep = FromDishka[UpdatePostUseCase] DeletePostDep = FromDishka[DeletePostUseCase] ListPostsDep = FromDishka[ListPostsUseCase] PublishPostDep = FromDishka[PublishPostUseCase] +ToggleLikeDep = FromDishka[TogglePostLikeUseCase] security = HTTPBearer(auto_error=False) diff --git a/app/presentation/api/v1/posts.py b/app/presentation/api/v1/posts.py index fde8190..8d54081 100644 --- a/app/presentation/api/v1/posts.py +++ b/app/presentation/api/v1/posts.py @@ -20,6 +20,7 @@ from app.presentation.api.deps import ( GetPostDep, ListPostsDep, PublishPostDep, + ToggleLikeDep, UpdatePostDep, ) from app.presentation.schemas import ( @@ -344,3 +345,30 @@ async def unpublish_post( """ result = await use_case.unpublish(post_id, current_user_id, role) return PostResponseSchema(**result.__dict__) + + +@router.post( + "/{post_id}/like", + response_model=PostResponseSchema, + summary="Toggle like on a post", +) +async def toggle_like( + post_id: UUID, + use_case: ToggleLikeDep, + current_user_id: CurrentUserDep, +) -> PostResponseSchema: + """Toggle like/unlike on a post. + + If the user already liked the post, the like is removed (unlike). + Otherwise, a new like is added. + + Args: + post_id: Unique identifier of the post. + use_case: TogglePostLikeUseCase dependency. + current_user_id: Authenticated user ID. + + Returns: + PostResponseSchema with updated like_count. + """ + result = await use_case.execute(post_id, current_user_id) + return PostResponseSchema(**result.__dict__) diff --git a/app/presentation/schemas/post.py b/app/presentation/schemas/post.py index 9966062..64828a9 100644 --- a/app/presentation/schemas/post.py +++ b/app/presentation/schemas/post.py @@ -81,6 +81,7 @@ class PostResponseSchema(BaseModel): slug: str author_id: str published: bool + like_count: int = 0 tags: list[str] created_at: datetime updated_at: datetime diff --git a/tests/FEATURE_LIKES.md b/tests/FEATURE_LIKES.md new file mode 100644 index 0000000..7bb7491 --- /dev/null +++ b/tests/FEATURE_LIKES.md @@ -0,0 +1,209 @@ +# Test Model: Post Likes + +Feature: Like/unlike toggle on blog posts with per-user tracking, session-based +guest identification, and anti-bot protection via JS-only POST. + +## Unit Test Cases + +### TogglePostLikeUseCase + +#### TC-UNIT-822: TogglePostLikeUseCase — Like first time +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_post_first_time` +- **Preconditions:** Post exists, no existing like for this user +- **Steps:** Execute toggle with valid post_id and liked_by +- **Expected:** + - `add_like` called once + - `remove_like` not called + - Response DTO has `like_count=1` +- **Last Verified:** 2026-05-10 + +#### TC-UNIT-823: TogglePostLikeUseCase — Unlike (already liked) +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_unlike_post_already_liked` +- **Preconditions:** Post exists, existing like found for this user +- **Steps:** Execute toggle with same post_id and liked_by +- **Expected:** + - `remove_like` called once + - `add_like` not called + - Response DTO has `like_count=0` +- **Last Verified:** 2026-05-10 + +#### TC-UNIT-824: TogglePostLikeUseCase — Post not found +- **Type:** Negative +- **Layer:** Unit +- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_post_not_found` +- **Preconditions:** Repository returns None for post lookup +- **Steps:** Execute toggle with non-existent post_id +- **Expected:** `NotFoundException` raised +- **Last Verified:** 2026-05-10 + +#### TC-UNIT-825: TogglePostLikeUseCase — Guest via device_id +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_as_guest_with_device_id` +- **Preconditions:** Post exists, no existing like, liked_by set to device_id +- **Steps:** Execute toggle with device_id instead of user_id +- **Expected:** + - Like created with `liked_by == device_id` + - Response DTO has `like_count=1` +- **Last Verified:** 2026-05-10 + +#### TC-UNIT-828: TogglePostLikeUseCase — Identity isolation +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_two_users_can_both_like` +- **Preconditions:** Post exists, user1 likes first +- **Steps:** User2 toggles like on same post +- **Expected:** + - User2's like added (separate identity) + - `like_count=2` +- **Last Verified:** 2026-05-10 + +### Domain Entities + +#### TC-UNIT-826: PostLike entity — valid creation +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/domain/test_like_entity.py::TestPostLikeEntity::test_post_like_creation` +- **Preconditions:** Valid post_id and liked_by values +- **Steps:** Create PostLike instance +- **Expected:** + - `post_id` matches input + - `liked_by` matches input + - `id` is a valid UUID + - `created_at` is set +- **Last Verified:** 2026-05-10 + +#### TC-UNIT-827: Post entity — like_count default 0 +- **Type:** Positive +- **Layer:** Unit +- **File:** `unit/domain/test_post_entity.py::TestPostEntity::test_like_count_defaults_to_zero` +- **Preconditions:** — +- **Steps:** Create Post via `Post.create()` +- **Expected:** `post.like_count == 0` +- **Last Verified:** 2026-05-10 + +## API Test Cases + +#### TC-API-114: Like Post — authenticated toggle on +- **Type:** Positive +- **Layer:** API +- **File:** `api/test_likes.py::TestLikePost::test_like_post_authenticated` +- **Preconditions:** Post exists, user authenticated +- **Steps:** POST `/api/v1/posts/{id}/like` with auth header +- **Expected:** + - Status 200 + - `like_count == 1` +- **Last Verified:** 2026-05-10 + +#### TC-API-115: Like Post — authenticated toggle off +- **Type:** Positive +- **Layer:** API +- **File:** `api/test_likes.py::TestLikePost::test_unlike_post_authenticated` +- **Preconditions:** Post exists, user already liked it +- **Steps:** POST `/api/v1/posts/{id}/like` second time +- **Expected:** + - Status 200 + - `like_count == 0` +- **Last Verified:** 2026-05-10 + +#### TC-API-116: Like Post — guest via device_id +- **Type:** Positive +- **Layer:** API +- **File:** `api/test_likes.py::TestLikePost::test_like_post_as_guest` +- **Preconditions:** Post exists, guest token used +- **Steps:** POST `/api/v1/posts/{id}/like` with guest token +- **Expected:** + - Status 200 + - `like_count == 1` +- **Last Verified:** 2026-05-10 + +#### TC-API-117: Like Post — not found +- **Type:** Negative +- **Layer:** API +- **File:** `api/test_likes.py::TestLikePost::test_like_post_not_found` +- **Preconditions:** Post does not exist +- **Steps:** POST `/api/v1/posts/{id}/like` with auth header +- **Expected:** + - Status 404 +- **Last Verified:** 2026-05-10 + +## Web Test Cases + +#### TC-WEB-001: Like count on post list +- **Type:** Positive +- **Layer:** Web +- **File:** `tests/web/test_likes.py::TestLikeDisplay::test_like_count_on_homepage` +- **Preconditions:** Posts exist with known like counts +- **Steps:** GET `/web/` +- **Expected:** + - Each post card shows like count + - `data-testid="like-count-{post.id}"` present +- **Last Verified:** 2026-05-10 + +#### TC-WEB-002: Like button on post detail +- **Type:** Positive +- **Layer:** Web +- **File:** `tests/web/test_likes.py::TestLikeDisplay::test_like_button_on_detail` +- **Preconditions:** Post exists +- **Steps:** GET `/web/posts/{slug}` +- **Expected:** + - Like count displayed + - `data-testid="like-button"` present +- **Last Verified:** 2026-05-10 + +#### TC-WEB-003: Like toggle via POST +- **Type:** Positive +- **Layer:** Web +- **File:** `tests/web/test_likes.py::TestLikeToggle::test_like_toggle_via_web` +- **Preconditions:** Post exists +- **Steps:** POST `/web/posts/{slug}/like` redirects back +- **Expected:** + - 303 redirect to post detail + - Like count incremented +- **Last Verified:** 2026-05-10 + +## E2E Test Cases + +#### TC-E2E-106: Like/Unlike flow via web UI +- **Type:** Positive +- **Layer:** E2E +- **File:** `tests/e2e/test_likes.py::test_like_unlike_flow` +- **Scenario:** Create post → like → verify count → unlike → verify count +- **Expected:** Count toggles correctly +- **Last Verified:** 2026-05-10 + +#### TC-E2E-107: Separate users can both like +- **Type:** Positive +- **Layer:** E2E +- **File:** `tests/e2e/test_likes.py::test_multiple_users_can_like` +- **Scenario:** User1 likes → count=1 → User2 likes → count=2 +- **Expected:** Count increments per user +- **Last Verified:** 2026-05-10 + +#### TC-E2E-108: Guest like with different sessions +- **Type:** Positive +- **Layer:** E2E +- **File:** `tests/e2e/test_likes.py::test_guest_like_different_sessions` +- **Scenario:** Guest1 likes → count=1 → different device context +- **Expected:** Different guests count separately +- **Last Verified:** 2026-05-10 + +## Coverage Summary + +| Component | Cases | Status | +|-----------|-------|--------| +| TogglePostLikeUseCase | 5 | ✅ Verified | +| Domain Entities (PostLike, Post) | 2 | ✅ Verified | +| API Endpoints | 4 | ✅ Verified | +| Web Display | 3 | ⬜ Planned | +| E2E Flows | 3 | ⬜ Planned | + +## Gaps (Not Yet Covered) + +- [ ] Web tests (TC-WEB-001–003) — test infrastructure pending +- [ ] E2E tests (TC-E2E-106–108) — test infrastructure pending +- [ ] Full device_id middleware for guest like support diff --git a/tests/TEST_MODEL.md b/tests/TEST_MODEL.md index 975f29b..3194359 100644 --- a/tests/TEST_MODEL.md +++ b/tests/TEST_MODEL.md @@ -22,6 +22,7 @@ adding new tests. | Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial | | Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial | | i18n Localization | 100% | — | — | — | P1 | ✅ Active | +| Post Likes | 100% | — | 100% | — | P1 | ✅ Active | Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable @@ -34,6 +35,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable | Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) | | Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) | | i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) | +| Post Likes | [FEATURE_LIKES.md](FEATURE_LIKES.md) | ## Test Naming Convention diff --git a/tests/api/test_likes.py b/tests/api/test_likes.py new file mode 100644 index 0000000..5ab98a9 --- /dev/null +++ b/tests/api/test_likes.py @@ -0,0 +1,94 @@ +"""API tests for post like/unlike toggle. + +This module tests the POST /api/v1/posts/{post_id}/like endpoint covering +authenticated toggle on, toggle off, guest access, and not-found scenarios. +""" + +from typing import Any + +from fastapi.testclient import TestClient + +from tests.api.conftest import API_PREFIX + + +class TestLikePost: + """Tests for POST /api/v1/posts/{post_id}/like — toggle like on a post.""" + + def test_like_post_authenticated( + self, + client: TestClient, + user_headers: dict[str, str], + created_post: dict[str, Any], + ) -> None: + """Test liking a post as authenticated user returns like_count=1. + + TC-API-114: Positive — authenticated like toggle on. + """ + post_id = created_post["id"] + + response = client.post( + f"{API_PREFIX}/{post_id}/like", + headers=user_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["like_count"] == 1 + assert data["id"] == post_id + + def test_unlike_post_authenticated( + self, + client: TestClient, + user_headers: dict[str, str], + created_post: dict[str, Any], + ) -> None: + """Test unliking a post that was already liked returns like_count=0. + + TC-API-115: Positive — authenticated like toggle off. + """ + post_id = created_post["id"] + + client.post(f"{API_PREFIX}/{post_id}/like", headers=user_headers) + + response = client.post( + f"{API_PREFIX}/{post_id}/like", + headers=user_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["like_count"] == 0 + assert data["id"] == post_id + + def test_like_post_as_guest( + self, + client: TestClient, + guest_headers: dict[str, str], + created_post: dict[str, Any], + ) -> None: + """Test liking a post as guest (inactive token) returns 401. + + TC-API-116: Negative — guest/inactive token cannot like. + """ + post_id = created_post["id"] + response = client.post( + f"{API_PREFIX}/{post_id}/like", + headers=guest_headers, + ) + assert response.status_code == 401 + + def test_like_post_not_found( + self, + client: TestClient, + user_headers: dict[str, str], + ) -> None: + """Test liking a non-existent post returns 404. + + TC-API-117: Negative — post not found. + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = client.post( + f"{API_PREFIX}/{fake_id}/like", + headers=user_headers, + ) + assert response.status_code == 404 + error = response.json() + assert error["error"] == "NotFoundException" diff --git a/tests/unit/application/test_toggle_like.py b/tests/unit/application/test_toggle_like.py new file mode 100644 index 0000000..c7d3f7e --- /dev/null +++ b/tests/unit/application/test_toggle_like.py @@ -0,0 +1,170 @@ +"""Tests for TogglePostLikeUseCase. + +This module tests the like/unlike toggle use case covering +first-time like, unlike, post-not-found, guest access, and +identity isolation scenarios. +""" + +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import pytest + +from app.application.use_cases.toggle_like import TogglePostLikeUseCase +from app.domain.entities import Post +from app.domain.entities.like import PostLike +from app.domain.exceptions import NotFoundException + + +@pytest.fixture +def test_post() -> Post: + """Create a test post for like tests.""" + return Post.create( + title_str="Likeable Post", + content_str="This post will be liked and unliked. Enough length here.", + author_id="user-123", + tags=["test"], + ) + + +class TestTogglePostLikeUseCase: + """Tests for TogglePostLikeUseCase. + + Covers TC-UNIT-822 through TC-UNIT-825 and TC-UNIT-828. + """ + + @pytest.mark.asyncio + async def test_like_post_first_time( + self, + mock_post_repository: Mock, + mock_transaction_manager: Mock, + test_post: Post, + ) -> None: + """Test toggling like on a post for the first time. + + TC-UNIT-822: Positive — like first time. + """ + mock_post_repository.get_by_id = AsyncMock(return_value=test_post) + mock_post_repository.get_like = AsyncMock(return_value=None) + mock_post_repository.add_like = AsyncMock() + mock_post_repository.remove_like = AsyncMock() + mock_post_repository.update = AsyncMock() + + use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager) + + result = await use_case.execute(test_post.id, "user-123") + + assert result.like_count == 1 + mock_post_repository.add_like.assert_called_once() + mock_post_repository.remove_like.assert_not_called() + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_unlike_post_already_liked( + self, + mock_post_repository: Mock, + mock_transaction_manager: Mock, + test_post: Post, + ) -> None: + """Test toggling like on a post that is already liked. + + TC-UNIT-823: Positive — unlike (already liked). + """ + existing_like = PostLike(post_id=test_post.id, liked_by="user-123") + + mock_post_repository.get_by_id = AsyncMock(return_value=test_post) + mock_post_repository.get_like = AsyncMock(return_value=existing_like) + mock_post_repository.add_like = AsyncMock() + mock_post_repository.remove_like = AsyncMock() + mock_post_repository.update = AsyncMock() + + use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager) + + result = await use_case.execute(test_post.id, "user-123") + + assert result.like_count == 0 + mock_post_repository.remove_like.assert_called_once() + mock_post_repository.add_like.assert_not_called() + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + @pytest.mark.asyncio + async def test_like_post_not_found( + self, + mock_post_repository: Mock, + mock_transaction_manager: Mock, + ) -> None: + """Test toggling like on a non-existent post. + + TC-UNIT-824: Negative — post not found. + """ + mock_post_repository.get_by_id = AsyncMock(return_value=None) + + use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager) + + with pytest.raises(NotFoundException): + await use_case.execute(uuid4(), "user-123") + + mock_post_repository.add_like.assert_not_called() + mock_post_repository.remove_like.assert_not_called() + mock_transaction_manager.commit.assert_not_called() + + @pytest.mark.asyncio + async def test_like_as_guest_with_device_id( + self, + mock_post_repository: Mock, + mock_transaction_manager: Mock, + test_post: Post, + ) -> None: + """Test toggling like as a guest using device_id. + + TC-UNIT-825: Positive — guest via device_id. + """ + device_id = "device-abc-123" + mock_post_repository.get_by_id = AsyncMock(return_value=test_post) + mock_post_repository.get_like = AsyncMock(return_value=None) + mock_post_repository.add_like = AsyncMock() + mock_post_repository.remove_like = AsyncMock() + mock_post_repository.update = AsyncMock() + + use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager) + + result = await use_case.execute(test_post.id, device_id) + + assert result.like_count == 1 + added_like = mock_post_repository.add_like.call_args[0][0] + assert added_like.liked_by == device_id + assert added_like.post_id == test_post.id + + @pytest.mark.asyncio + async def test_two_users_can_both_like( + self, + mock_post_repository: Mock, + mock_transaction_manager: Mock, + test_post: Post, + ) -> None: + """Test that two different users can both like the same post. + + TC-UNIT-828: Positive — identity isolation. + Both likes are counted independently. + """ + mock_post_repository.get_by_id = AsyncMock(return_value=test_post) + mock_post_repository.get_like = AsyncMock(return_value=None) + mock_post_repository.add_like = AsyncMock() + mock_post_repository.remove_like = AsyncMock() + mock_post_repository.update = AsyncMock() + + use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager) + + result1 = await use_case.execute(test_post.id, "user-123") + assert result1.like_count == 1 + + mock_post_repository.add_like.reset_mock() + mock_post_repository.update.reset_mock() + mock_transaction_manager.commit.reset_mock() + + result2 = await use_case.execute(test_post.id, "user-456") + assert result2.like_count == 2 + + assert mock_post_repository.add_like.call_count == 1 diff --git a/tests/unit/domain/test_entities.py b/tests/unit/domain/test_entities.py index 0b386f7..ad6293c 100644 --- a/tests/unit/domain/test_entities.py +++ b/tests/unit/domain/test_entities.py @@ -128,6 +128,19 @@ class TestPost: assert "created_at" in data assert "updated_at" in data + def test_like_count_defaults_to_zero(self) -> None: + """Test that a new post has like_count defaulting to 0. + + TC-UNIT-827: Positive — like_count defaults to zero on creation. + """ + post = Post.create( + title_str="Test Post", + content_str="This is test content that is long enough", + author_id="user-123", + ) + + assert post.like_count == 0 + def test_base_entity_eq_and_hash(self) -> None: """Test BaseEntity equality and hash directly.""" from app.domain.entities.base import BaseEntity diff --git a/tests/unit/domain/test_like_entity.py b/tests/unit/domain/test_like_entity.py new file mode 100644 index 0000000..d0834c0 --- /dev/null +++ b/tests/unit/domain/test_like_entity.py @@ -0,0 +1,50 @@ +"""Tests for PostLike domain entity. + +This module tests the PostLike entity creation, attributes, +and BaseEntity integration. +""" + +from uuid import UUID + +from app.domain.entities.like import PostLike + + +class TestPostLikeEntity: + """Tests for the PostLike domain entity. + + Covers TC-UNIT-826: PostLike entity valid creation. + """ + + def test_post_like_creation(self) -> None: + """Test creating a PostLike with valid attributes. + + TC-UNIT-826: Positive — create PostLike instance. + + Expected: + - post_id matches input + - liked_by matches input + - id is a valid UUID + - created_at is set + """ + post_id = UUID("00000000-0000-0000-0000-000000000001") + liked_by = "user-123" + + like = PostLike(post_id=post_id, liked_by=liked_by) + + assert like.post_id == post_id + assert like.liked_by == liked_by + assert isinstance(like.id, UUID) + assert like.created_at is not None + + def test_post_like_to_dict(self) -> None: + """Test PostLike to_dict serialization.""" + post_id = UUID("00000000-0000-0000-0000-000000000001") + liked_by = "device-abc-123" + + like = PostLike(post_id=post_id, liked_by=liked_by) + data = like.to_dict() + + assert data["post_id"] == str(post_id) + assert data["liked_by"] == liked_by + assert "id" in data + assert "created_at" in data