feat: add like/unlike toggle on blog posts with per-user tracking

- PostLike domain entity (post_id, liked_by) with BaseEntity integration
- Post entity: add like_count field (default 0) and to_dict serialization
- PostRepository interface: add get_like, add_like, remove_like methods
- TogglePostLikeUseCase: toggle logic (like → unlike, unlike → like)
- PostResponseDTO/PostResponseSchema: add like_count field
- PostLikeORM model with FK to posts and cascade delete
- SQLAlchemyPostRepository: implement like query/add/remove with ORM mapping
- DI provider registration for TogglePostLikeUseCase
- API endpoint POST /api/v1/posts/{id}/like (auth required)
- Unit tests: PostLike entity, Post.like_count, TogglePostLikeUseCase (7 tests)
- API tests: POST /api/v1/posts/{id}/like (4 tests)
- Test model files: FEATURE_LIKES.md, TEST_MODEL.md updated
This commit is contained in:
2026-05-10 18:24:09 +03:00
parent 4497f452a1
commit 3cf6c94da2
21 changed files with 876 additions and 6 deletions

View File

@@ -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,
)