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

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

View File

@@ -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"]

View File

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

View File

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

View File

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