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:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
40
app/domain/entities/like.py
Normal file
40
app/domain/entities/like.py
Normal 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(),
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user