Comments (#17)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
This commit was merged in pull request #17.
This commit is contained in:
@@ -4,14 +4,24 @@ This module re-exports all application layer components including
|
||||
DTOs, interfaces, and use cases for convenient importing.
|
||||
"""
|
||||
|
||||
from app.application.dtos import CreatePostDTO, PostResponseDTO, UpdatePostDTO
|
||||
from app.application.dtos import (
|
||||
CommentResponseDTO,
|
||||
CreateCommentDTO,
|
||||
CreatePostDTO,
|
||||
PostResponseDTO,
|
||||
UpdatePostDTO,
|
||||
)
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.application.use_cases import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeleteCommentUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -20,6 +30,8 @@ __all__ = [
|
||||
"CreatePostDTO",
|
||||
"UpdatePostDTO",
|
||||
"PostResponseDTO",
|
||||
"CreateCommentDTO",
|
||||
"CommentResponseDTO",
|
||||
"TransactionManager",
|
||||
"CreatePostUseCase",
|
||||
"GetPostUseCase",
|
||||
@@ -28,4 +40,8 @@ __all__ = [
|
||||
"ListPostsUseCase",
|
||||
"PublishPostUseCase",
|
||||
"TogglePostLikeUseCase",
|
||||
"CreateCommentUseCase",
|
||||
"DeleteCommentUseCase",
|
||||
"ListCommentsUseCase",
|
||||
"ToggleCommentLikeUseCase",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,13 @@ This module re-exports all Data Transfer Objects used in the
|
||||
application layer for data communication.
|
||||
"""
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO, CreateCommentDTO
|
||||
from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO
|
||||
|
||||
__all__ = ["CreatePostDTO", "UpdatePostDTO", "PostResponseDTO"]
|
||||
__all__ = [
|
||||
"CreatePostDTO",
|
||||
"UpdatePostDTO",
|
||||
"PostResponseDTO",
|
||||
"CreateCommentDTO",
|
||||
"CommentResponseDTO",
|
||||
]
|
||||
|
||||
55
app/application/dtos/comment.py
Normal file
55
app/application/dtos/comment.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""DTOs for comment use cases.
|
||||
|
||||
This module defines Data Transfer Objects used for communication between
|
||||
application layer comment use cases and presentation layer.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreateCommentDTO:
|
||||
"""DTO for creating a comment.
|
||||
|
||||
Carries comment creation data from API to use case.
|
||||
|
||||
Attributes:
|
||||
post_id: UUID of the post to comment on.
|
||||
author_id: Identifier of the comment author.
|
||||
content: Comment content string (Markdown supported).
|
||||
parent_id: Optional UUID of parent comment for replies.
|
||||
"""
|
||||
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: str
|
||||
parent_id: UUID | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentResponseDTO:
|
||||
"""DTO for comment response.
|
||||
|
||||
Carries complete comment data for API responses.
|
||||
|
||||
Attributes:
|
||||
id: Unique comment identifier.
|
||||
post_id: UUID of the parent post.
|
||||
author_id: Comment author identifier.
|
||||
content: Comment content string.
|
||||
parent_id: Optional UUID of parent comment.
|
||||
like_count: Number of likes on this comment.
|
||||
created_at: Creation timestamp.
|
||||
updated_at: Last update timestamp.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: str
|
||||
parent_id: UUID | None = None
|
||||
like_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
@@ -101,3 +101,4 @@ class PostResponseDTO:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
like_count: int = 0
|
||||
comment_count: int = 0
|
||||
|
||||
@@ -4,11 +4,15 @@ This module re-exports all application use cases that implement
|
||||
business logic operations for the blog API.
|
||||
"""
|
||||
|
||||
from app.application.use_cases.create_comment import CreateCommentUseCase
|
||||
from app.application.use_cases.create_post import CreatePostUseCase
|
||||
from app.application.use_cases.delete_comment import DeleteCommentUseCase
|
||||
from app.application.use_cases.delete_post import DeletePostUseCase
|
||||
from app.application.use_cases.get_post import GetPostUseCase
|
||||
from app.application.use_cases.list_comments import ListCommentsUseCase
|
||||
from app.application.use_cases.list_posts import ListPostsUseCase
|
||||
from app.application.use_cases.publish_post import PublishPostUseCase
|
||||
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
|
||||
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
|
||||
from app.application.use_cases.update_post import UpdatePostUseCase
|
||||
|
||||
@@ -20,4 +24,8 @@ __all__ = [
|
||||
"ListPostsUseCase",
|
||||
"PublishPostUseCase",
|
||||
"TogglePostLikeUseCase",
|
||||
"CreateCommentUseCase",
|
||||
"DeleteCommentUseCase",
|
||||
"ListCommentsUseCase",
|
||||
"ToggleCommentLikeUseCase",
|
||||
]
|
||||
|
||||
100
app/application/use_cases/create_comment.py
Normal file
100
app/application/use_cases/create_comment.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Create comment use case.
|
||||
|
||||
This module implements the use case for creating comments on blog posts.
|
||||
Supports both top-level comments and nested replies via parent_id.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.exceptions import NotFoundException
|
||||
from app.domain.repositories import CommentRepository, PostRepository
|
||||
|
||||
|
||||
class CreateCommentUseCase:
|
||||
"""Use case for creating a comment on a blog post.
|
||||
|
||||
Handles top-level comments and replies to existing comments.
|
||||
Validates that the target post exists before creating.
|
||||
|
||||
Attributes:
|
||||
_post_repo: Repository for post data access.
|
||||
_comment_repo: Repository for comment data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
post_repo: Repository for post operations.
|
||||
comment_repo: Repository for comment operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._post_repo = post_repo
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
post_id: UUID,
|
||||
author_id: str,
|
||||
content: str,
|
||||
parent_id: UUID | None = None,
|
||||
) -> CommentResponseDTO:
|
||||
"""Execute the use case to create a comment.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
author_id: Identifier of the comment author.
|
||||
content: Comment content (Markdown supported).
|
||||
parent_id: Optional UUID of parent comment for replies.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with created comment data.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If the target post 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")
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
await self._comment_repo.add(comment)
|
||||
await self._tx_manager.commit()
|
||||
|
||||
return self._map_to_dto(comment)
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
60
app/application/use_cases/delete_comment.py
Normal file
60
app/application/use_cases/delete_comment.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Delete comment use case.
|
||||
|
||||
This module implements the use case for deleting comments.
|
||||
Users can delete their own comments.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.exceptions import ForbiddenException, NotFoundException
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class DeleteCommentUseCase:
|
||||
"""Use case for deleting a comment.
|
||||
|
||||
Allows users to delete their own comments.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
comment_id: UUID,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""Delete a comment.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to delete.
|
||||
user_id: Identifier of the user requesting deletion.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If the comment does not exist.
|
||||
"""
|
||||
comment = await self._comment_repo.get_by_id(comment_id)
|
||||
if not comment:
|
||||
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||
|
||||
if comment.author_id != user_id:
|
||||
raise ForbiddenException("You are not allowed to delete this comment")
|
||||
|
||||
await self._comment_repo.delete(comment_id)
|
||||
await self._tx_manager.commit()
|
||||
63
app/application/use_cases/list_comments.py
Normal file
63
app/application/use_cases/list_comments.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""List comments use case.
|
||||
|
||||
This module implements the use case for listing comments on a blog post.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class ListCommentsUseCase:
|
||||
"""Use case for listing comments on a blog post.
|
||||
|
||||
Retrieves all comments for a given post ordered by creation time.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment data access.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment operations.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
|
||||
async def execute(self, post_id: UUID) -> list[CommentResponseDTO]:
|
||||
"""List all comments for a post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
List of CommentResponseDTO for the post.
|
||||
"""
|
||||
comments = await self._comment_repo.get_by_post(post_id)
|
||||
return [self._map_to_dto(c) for c in comments]
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
96
app/application/use_cases/toggle_comment_like.py
Normal file
96
app/application/use_cases/toggle_comment_like.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Toggle comment like use case.
|
||||
|
||||
This module implements the use case for toggling likes on comments.
|
||||
If the user already liked the comment, the like is removed (unlike).
|
||||
If not, a new like is added.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.application.dtos.comment import CommentResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
from app.domain.repositories import CommentRepository
|
||||
|
||||
|
||||
class ToggleCommentLikeUseCase:
|
||||
"""Use case for toggling a like on a comment.
|
||||
|
||||
Handles like/unlike toggle logic. If the user has already liked
|
||||
the comment, the like is removed. Otherwise, a new like is created.
|
||||
|
||||
Attributes:
|
||||
_comment_repo: Repository for comment and like data access.
|
||||
_tx_manager: Transaction manager for commit control.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> None:
|
||||
"""Initialize use case with dependencies.
|
||||
|
||||
Args:
|
||||
comment_repo: Repository for comment and like operations.
|
||||
tx_manager: Transaction manager instance.
|
||||
"""
|
||||
self._comment_repo = comment_repo
|
||||
self._tx_manager = tx_manager
|
||||
|
||||
async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO:
|
||||
"""Toggle like on a comment.
|
||||
|
||||
If the user already liked the comment, remove the like.
|
||||
Otherwise, add a new like.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to toggle like on.
|
||||
liked_by: User ID.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with updated like_count.
|
||||
|
||||
Raises:
|
||||
NotFoundException: If comment with given ID does not exist.
|
||||
"""
|
||||
comment = await self._comment_repo.get_by_id(comment_id)
|
||||
if not comment:
|
||||
raise NotFoundException(f"Comment with id '{comment_id}' not found")
|
||||
|
||||
existing_like = await self._comment_repo.get_like(comment_id, liked_by)
|
||||
|
||||
if existing_like:
|
||||
await self._comment_repo.remove_like(comment_id, liked_by)
|
||||
comment.like_count = max(0, comment.like_count - 1)
|
||||
else:
|
||||
new_like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
await self._comment_repo.add_like(new_like)
|
||||
comment.like_count += 1
|
||||
|
||||
await self._comment_repo.update(comment)
|
||||
await self._tx_manager.commit()
|
||||
|
||||
return self._map_to_dto(comment)
|
||||
|
||||
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
|
||||
"""Map domain entity to response DTO.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
CommentResponseDTO with all comment attributes including like_count.
|
||||
"""
|
||||
return CommentResponseDTO(
|
||||
id=comment.id,
|
||||
post_id=comment.post_id,
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=comment.parent_id,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
@@ -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, PostLike
|
||||
from app.domain.entities import BaseEntity, Comment, CommentLike, Post, PostLike
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
@@ -13,19 +13,22 @@ from app.domain.exceptions import (
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import PostRepository, Repository
|
||||
from app.domain.repositories import CommentRepository, PostRepository, Repository
|
||||
from app.domain.value_objects import Content, Slug, Title, ValueObject
|
||||
|
||||
__all__ = [
|
||||
"BaseEntity",
|
||||
"Post",
|
||||
"PostLike",
|
||||
"Comment",
|
||||
"CommentLike",
|
||||
"ValueObject",
|
||||
"Title",
|
||||
"Content",
|
||||
"Slug",
|
||||
"Repository",
|
||||
"PostRepository",
|
||||
"CommentRepository",
|
||||
"DomainException",
|
||||
"ValidationException",
|
||||
"NotFoundException",
|
||||
|
||||
@@ -5,7 +5,9 @@ core business objects with identity.
|
||||
"""
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.entities.post import Post
|
||||
|
||||
__all__ = ["BaseEntity", "Post", "PostLike"]
|
||||
__all__ = ["BaseEntity", "Post", "PostLike", "Comment", "CommentLike"]
|
||||
|
||||
79
app/domain/entities/comment.py
Normal file
79
app/domain/entities/comment.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Domain entity for Comment.
|
||||
|
||||
This module defines the Comment entity that represents a comment on a blog
|
||||
post. Comments can be top-level (parent_id=None) or replies to other
|
||||
comments (parent_id set).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Comment(BaseEntity):
|
||||
"""Comment domain entity.
|
||||
|
||||
Represents a comment on a blog post with optional parent reference
|
||||
for nested replies. Supports Markdown content and like tracking.
|
||||
|
||||
Attributes:
|
||||
post_id: UUID of the post this comment belongs to.
|
||||
author_id: Identifier of the comment author.
|
||||
content: CommentContent value object with Markdown text.
|
||||
parent_id: UUID of parent comment for replies, or None.
|
||||
like_count: Number of likes on this comment.
|
||||
"""
|
||||
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: CommentContent
|
||||
parent_id: UUID | None = None
|
||||
like_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation with all comment attributes.
|
||||
"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"post_id": str(self.post_id),
|
||||
"author_id": self.author_id,
|
||||
"content": self.content.value,
|
||||
"parent_id": str(self.parent_id) if self.parent_id else None,
|
||||
"like_count": self.like_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
post_id: UUID,
|
||||
author_id: str,
|
||||
content_str: str,
|
||||
parent_id: UUID | None = None,
|
||||
) -> "Comment":
|
||||
"""Factory method to create a new comment.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
author_id: Identifier of the comment author.
|
||||
content_str: Comment content string (Markdown supported).
|
||||
parent_id: Optional UUID of parent comment for replies.
|
||||
|
||||
Returns:
|
||||
New Comment instance with validated content.
|
||||
"""
|
||||
content = CommentContent(content_str)
|
||||
return cls(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
40
app/domain/entities/comment_like.py
Normal file
40
app/domain/entities/comment_like.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Domain entity for CommentLike.
|
||||
|
||||
This module defines the CommentLike entity that tracks which users
|
||||
have liked which comments.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class CommentLike(BaseEntity):
|
||||
"""Comment like domain entity.
|
||||
|
||||
Tracks a like on a comment by a user. Each like is uniquely
|
||||
identified by its entity ID.
|
||||
|
||||
Attributes:
|
||||
comment_id: UUID of the liked comment.
|
||||
liked_by: Identifier of the user who liked.
|
||||
"""
|
||||
|
||||
comment_id: UUID
|
||||
liked_by: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary with all CommentLike attributes.
|
||||
"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"comment_id": str(self.comment_id),
|
||||
"liked_by": self.liked_by,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -5,6 +5,7 @@ the contract for data access operations.
|
||||
"""
|
||||
|
||||
from app.domain.repositories.base import Repository
|
||||
from app.domain.repositories.comment import CommentRepository
|
||||
from app.domain.repositories.post import PostRepository
|
||||
|
||||
__all__ = ["Repository", "PostRepository"]
|
||||
__all__ = ["Repository", "PostRepository", "CommentRepository"]
|
||||
|
||||
80
app/domain/repositories/comment.py
Normal file
80
app/domain/repositories/comment.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Comment repository interface.
|
||||
|
||||
This module defines the repository interface for Comment entities
|
||||
including nested comment queries and like management.
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.repositories.base import Repository
|
||||
|
||||
|
||||
class CommentRepository(Repository[Comment]):
|
||||
"""Repository interface for Comments.
|
||||
|
||||
Extends the generic repository with comment-specific operations
|
||||
including post-based listing and like management.
|
||||
|
||||
Example:
|
||||
>>> comments = await repo.get_by_post(post_id)
|
||||
>>> like = await repo.get_like(comment_id, "user-123")
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_post(self, post_id: UUID) -> list[Comment]:
|
||||
"""Get all comments for a post, ordered by creation time.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
List of Comment entities for the post.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
|
||||
"""Get a like by comment and user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
|
||||
Returns:
|
||||
CommentLike if found, None otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def add_like(self, like: CommentLike) -> None:
|
||||
"""Add a new like to a comment.
|
||||
|
||||
Args:
|
||||
like: CommentLike entity to add.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def count_by_post(self, post_id: UUID) -> int:
|
||||
"""Get comment count for a post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
Number of comments on the post.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
|
||||
"""Remove a like from a comment by user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
"""
|
||||
...
|
||||
@@ -5,8 +5,9 @@ immutable validated domain concepts.
|
||||
"""
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
from app.domain.value_objects.content import Content
|
||||
from app.domain.value_objects.slug import Slug
|
||||
from app.domain.value_objects.title import Title
|
||||
|
||||
__all__ = ["ValueObject", "Title", "Content", "Slug"]
|
||||
__all__ = ["ValueObject", "Title", "Content", "Slug", "CommentContent"]
|
||||
|
||||
47
app/domain/value_objects/comment_content.py
Normal file
47
app/domain/value_objects/comment_content.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Value object for comment content.
|
||||
|
||||
This module defines the CommentContent value object that validates
|
||||
and encapsulates comment text content.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommentContent(ValueObject[str]):
|
||||
"""Comment content value object.
|
||||
|
||||
Wraps and validates comment content ensuring it meets length
|
||||
requirements and is not empty.
|
||||
|
||||
Attributes:
|
||||
value: The comment content string.
|
||||
MAX_LENGTH: Maximum allowed content length (5000 characters).
|
||||
|
||||
Raises:
|
||||
ValueError: If content is empty or too long.
|
||||
|
||||
Example:
|
||||
>>> content = CommentContent("This is a **bold** comment.")
|
||||
>>> content.value
|
||||
'This is a **bold** comment.'
|
||||
"""
|
||||
|
||||
MAX_LENGTH: int = 5000
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate comment content.
|
||||
|
||||
Checks that content is a non-empty string within length bounds.
|
||||
|
||||
Raises:
|
||||
ValueError: If content fails validation criteria.
|
||||
"""
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("Comment content must be a string")
|
||||
if not self.value.strip():
|
||||
raise ValueError("Comment content cannot be empty")
|
||||
if len(self.value) > self.MAX_LENGTH:
|
||||
raise ValueError(f"Comment content must be at most {self.MAX_LENGTH} characters")
|
||||
@@ -4,10 +4,12 @@ This module defines the database ORM models that map to database tables.
|
||||
Models are used by repositories for data persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -43,7 +45,10 @@ class PostORM(Base): # type: ignore[valid-type,misc]
|
||||
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(
|
||||
likes: Mapped[list[PostLikeORM]] = relationship(
|
||||
back_populates="post", cascade="all, delete-orphan"
|
||||
)
|
||||
comments: Mapped[list[CommentORM]] = relationship(
|
||||
back_populates="post", cascade="all, delete-orphan"
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
@@ -87,3 +92,87 @@ class PostLikeORM(Base): # type: ignore[valid-type,misc]
|
||||
)
|
||||
|
||||
post: Mapped[PostORM] = relationship(back_populates="likes")
|
||||
|
||||
|
||||
class CommentORM(Base): # type: ignore[valid-type,misc]
|
||||
"""SQLAlchemy model for Comment.
|
||||
|
||||
Database table representation of comments on blog posts.
|
||||
Maps to the 'comments' table with support for nested replies.
|
||||
|
||||
Attributes:
|
||||
id: Primary key as UUID string.
|
||||
post_id: Foreign key to the parent post.
|
||||
author_id: Comment author reference.
|
||||
content: Comment text content (Markdown supported).
|
||||
parent_id: Optional foreign key to parent comment (replies).
|
||||
like_count: Number of likes on this comment.
|
||||
created_at: Creation timestamp.
|
||||
updated_at: Last update timestamp.
|
||||
"""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
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
|
||||
)
|
||||
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("comments.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
like_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
post: Mapped[PostORM] = relationship(back_populates="comments")
|
||||
replies: Mapped[list[CommentORM]] = relationship(
|
||||
back_populates="parent",
|
||||
)
|
||||
parent: Mapped[CommentORM | None] = relationship(
|
||||
back_populates="replies",
|
||||
remote_side="CommentORM.id",
|
||||
)
|
||||
|
||||
|
||||
class CommentLikeORM(Base): # type: ignore[valid-type,misc]
|
||||
"""SQLAlchemy model for CommentLike.
|
||||
|
||||
Database table representation of comment likes.
|
||||
Maps to the 'comment_likes' table tracking which users liked which comments.
|
||||
|
||||
Attributes:
|
||||
id: Primary key as UUID string.
|
||||
comment_id: Foreign key to the liked comment.
|
||||
liked_by: User identifier.
|
||||
created_at: Creation timestamp.
|
||||
"""
|
||||
|
||||
__tablename__ = "comment_likes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("comments.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,
|
||||
)
|
||||
|
||||
@@ -10,19 +10,24 @@ from dishka import Provider, Scope, provide
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.application import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeleteCommentUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.domain.repositories import CommentRepository, PostRepository
|
||||
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient
|
||||
from app.infrastructure.config.settings import settings
|
||||
from app.infrastructure.database.connection import AsyncSessionLocal, engine
|
||||
from app.infrastructure.repositories.comment import SQLAlchemyCommentRepository
|
||||
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||
|
||||
|
||||
@@ -81,6 +86,18 @@ class RepositoryProvider(Provider):
|
||||
"""
|
||||
return SQLAlchemyPostRepository(session)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_comment_repository(self, session: AsyncSession) -> CommentRepository:
|
||||
"""Provide CommentRepository implementation.
|
||||
|
||||
Args:
|
||||
session: Database session from DI container.
|
||||
|
||||
Returns:
|
||||
SQLAlchemyCommentRepository instance.
|
||||
"""
|
||||
return SQLAlchemyCommentRepository(session)
|
||||
|
||||
|
||||
class TransactionManagerProvider(Provider):
|
||||
"""Provider for transaction manager.
|
||||
@@ -257,6 +274,86 @@ class UseCaseProvider(Provider):
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_create_comment_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> CreateCommentUseCase:
|
||||
"""Provide CreateCommentUseCase.
|
||||
|
||||
Args:
|
||||
post_repo: Post repository dependency.
|
||||
comment_repo: Comment repository dependency.
|
||||
tx_manager: Transaction manager dependency.
|
||||
|
||||
Returns:
|
||||
Configured CreateCommentUseCase instance.
|
||||
"""
|
||||
return CreateCommentUseCase(
|
||||
post_repo=post_repo,
|
||||
comment_repo=comment_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_list_comments_use_case(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
) -> ListCommentsUseCase:
|
||||
"""Provide ListCommentsUseCase.
|
||||
|
||||
Args:
|
||||
comment_repo: Comment repository dependency.
|
||||
|
||||
Returns:
|
||||
Configured ListCommentsUseCase instance.
|
||||
"""
|
||||
return ListCommentsUseCase(
|
||||
comment_repo=comment_repo,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_delete_comment_use_case(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> DeleteCommentUseCase:
|
||||
"""Provide DeleteCommentUseCase.
|
||||
|
||||
Args:
|
||||
comment_repo: Comment repository dependency.
|
||||
tx_manager: Transaction manager dependency.
|
||||
|
||||
Returns:
|
||||
Configured DeleteCommentUseCase instance.
|
||||
"""
|
||||
return DeleteCommentUseCase(
|
||||
comment_repo=comment_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_toggle_comment_like_use_case(
|
||||
self,
|
||||
comment_repo: CommentRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> ToggleCommentLikeUseCase:
|
||||
"""Provide ToggleCommentLikeUseCase.
|
||||
|
||||
Args:
|
||||
comment_repo: Comment repository dependency.
|
||||
tx_manager: Transaction manager dependency.
|
||||
|
||||
Returns:
|
||||
Configured ToggleCommentLikeUseCase instance.
|
||||
"""
|
||||
return ToggleCommentLikeUseCase(
|
||||
comment_repo=comment_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
|
||||
class KeycloakProvider(Provider):
|
||||
"""Provider for Keycloak authentication client.
|
||||
|
||||
@@ -43,6 +43,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"post.edit": "Edit",
|
||||
"post.delete": "Delete",
|
||||
"post.delete_confirm": "Are you sure you want to delete this post?",
|
||||
"post.comments": "Comments",
|
||||
"post.write_comment": "Write a Comment",
|
||||
"post.comment_placeholder": "Write a comment... Markdown is supported.",
|
||||
"post.replying_to": "Replying to",
|
||||
"post.cancel_reply": "Cancel reply",
|
||||
"post.cancel": "Cancel",
|
||||
"post.submit_comment": "Submit Comment",
|
||||
"post.reply": "Reply",
|
||||
"post.no_comments": "No comments yet. Be the first to comment!",
|
||||
"post.comment_error": "Failed to post comment. Please try again.",
|
||||
"post_form.title_edit": "Edit Post",
|
||||
"post_form.title_new": "New Post",
|
||||
"post_form.page_title_edit": "Edit Post",
|
||||
@@ -125,6 +135,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"post.edit": "Редактировать",
|
||||
"post.delete": "Удалить",
|
||||
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
|
||||
"post.comments": "Комментарии",
|
||||
"post.write_comment": "Написать комментарий",
|
||||
"post.comment_placeholder": "Напишите комментарий... Поддерживается Markdown.",
|
||||
"post.replying_to": "Ответ",
|
||||
"post.cancel_reply": "Отменить ответ",
|
||||
"post.cancel": "Отмена",
|
||||
"post.submit_comment": "Отправить",
|
||||
"post.reply": "Ответить",
|
||||
"post.no_comments": "Пока нет комментариев. Будьте первым!",
|
||||
"post.comment_error": "Не удалось отправить комментарий. Попробуйте снова.",
|
||||
"post_form.title_edit": "Редактировать статью",
|
||||
"post_form.title_new": "Новая статья",
|
||||
"post_form.page_title_edit": "Редактировать статью",
|
||||
@@ -207,6 +227,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"post.edit": "Modifier",
|
||||
"post.delete": "Supprimer",
|
||||
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
|
||||
"post.comments": "Commentaires",
|
||||
"post.write_comment": "Écrire un commentaire",
|
||||
"post.comment_placeholder": "Écrivez un commentaire... Markdown est supporté.",
|
||||
"post.replying_to": "Répondre à",
|
||||
"post.cancel_reply": "Annuler la réponse",
|
||||
"post.cancel": "Annuler",
|
||||
"post.submit_comment": "Soumettre",
|
||||
"post.reply": "Répondre",
|
||||
"post.no_comments": "Aucun commentaire pour le moment. Soyez le premier !",
|
||||
"post.comment_error": "Échec de l'envoi du commentaire. Veuillez réessayer.",
|
||||
"post_form.title_edit": "Modifier l'article",
|
||||
"post_form.title_new": "Nouvel article",
|
||||
"post_form.page_title_edit": "Modifier l'article",
|
||||
@@ -289,6 +319,16 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"post.edit": "Bearbeiten",
|
||||
"post.delete": "Löschen",
|
||||
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
|
||||
"post.comments": "Kommentare",
|
||||
"post.write_comment": "Kommentar schreiben",
|
||||
"post.comment_placeholder": "Schreiben Sie einen Kommentar... Markdown wird unterstützt.",
|
||||
"post.replying_to": "Antwort an",
|
||||
"post.cancel_reply": "Antwort abbrechen",
|
||||
"post.cancel": "Abbrechen",
|
||||
"post.submit_comment": "Absenden",
|
||||
"post.reply": "Antworten",
|
||||
"post.no_comments": "Noch keine Kommentare. Seien Sie der Erste!",
|
||||
"post.comment_error": "Kommentar konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
|
||||
"post_form.title_edit": "Beitrag bearbeiten",
|
||||
"post_form.title_new": "Neuer Beitrag",
|
||||
"post_form.page_title_edit": "Beitrag bearbeiten",
|
||||
|
||||
240
app/infrastructure/repositories/comment.py
Normal file
240
app/infrastructure/repositories/comment.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""SQLAlchemy implementation of CommentRepository.
|
||||
|
||||
This module provides the concrete implementation of CommentRepository
|
||||
using SQLAlchemy ORM for data persistence.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.repositories import CommentRepository
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
from app.infrastructure.database.models import CommentLikeORM, CommentORM
|
||||
|
||||
|
||||
class SQLAlchemyCommentRepository(CommentRepository):
|
||||
"""SQLAlchemy implementation of Comment repository.
|
||||
|
||||
Provides data access methods for Comment entities using SQLAlchemy ORM.
|
||||
Handles conversion between domain entities and ORM models.
|
||||
|
||||
Attributes:
|
||||
_session: SQLAlchemy async session for database operations.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""Initialize repository with session.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy async session instance.
|
||||
"""
|
||||
self._session = session
|
||||
|
||||
def _to_domain(self, orm: CommentORM) -> Comment:
|
||||
"""Convert ORM model to domain entity.
|
||||
|
||||
Args:
|
||||
orm: SQLAlchemy ORM model instance.
|
||||
|
||||
Returns:
|
||||
Domain Comment entity with validated value objects.
|
||||
"""
|
||||
return Comment(
|
||||
id=UUID(orm.id),
|
||||
post_id=UUID(orm.post_id),
|
||||
author_id=orm.author_id,
|
||||
content=CommentContent(orm.content),
|
||||
parent_id=UUID(orm.parent_id) if orm.parent_id else None,
|
||||
like_count=orm.like_count,
|
||||
created_at=orm.created_at,
|
||||
updated_at=orm.updated_at,
|
||||
)
|
||||
|
||||
def _to_orm(self, comment: Comment) -> CommentORM:
|
||||
"""Convert domain entity to ORM model.
|
||||
|
||||
Args:
|
||||
comment: Domain Comment entity.
|
||||
|
||||
Returns:
|
||||
SQLAlchemy ORM model instance.
|
||||
"""
|
||||
return CommentORM(
|
||||
id=str(comment.id),
|
||||
post_id=str(comment.post_id),
|
||||
author_id=comment.author_id,
|
||||
content=comment.content.value,
|
||||
parent_id=str(comment.parent_id) if comment.parent_id else None,
|
||||
like_count=comment.like_count,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
|
||||
async def get_by_id(self, entity_id: UUID) -> Comment | None:
|
||||
"""Get comment by ID.
|
||||
|
||||
Args:
|
||||
entity_id: Unique identifier of the comment.
|
||||
|
||||
Returns:
|
||||
Comment entity if found, None otherwise.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
return self._to_domain(orm) if orm else None
|
||||
|
||||
async def get_all(self) -> list[Comment]:
|
||||
"""Get all comments.
|
||||
|
||||
Returns:
|
||||
List of all Comment entities.
|
||||
"""
|
||||
result = await self._session.execute(select(CommentORM))
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def add(self, entity: Comment) -> None:
|
||||
"""Add new comment.
|
||||
|
||||
Args:
|
||||
entity: Comment entity to add.
|
||||
"""
|
||||
orm = self._to_orm(entity)
|
||||
self._session.add(orm)
|
||||
|
||||
async def update(self, entity: Comment) -> None:
|
||||
"""Update existing comment.
|
||||
|
||||
Args:
|
||||
entity: Comment entity with updated data.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentORM).where(CommentORM.id == str(entity.id))
|
||||
)
|
||||
orm = result.scalar_one()
|
||||
|
||||
orm.content = entity.content.value
|
||||
orm.like_count = entity.like_count
|
||||
orm.updated_at = entity.updated_at
|
||||
|
||||
async def delete(self, entity_id: UUID) -> None:
|
||||
"""Delete comment by ID.
|
||||
|
||||
Args:
|
||||
entity_id: Unique identifier of the comment to delete.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if orm:
|
||||
await self._session.delete(orm)
|
||||
|
||||
async def exists(self, entity_id: UUID) -> bool:
|
||||
"""Check if comment exists.
|
||||
|
||||
Args:
|
||||
entity_id: Unique identifier of the comment.
|
||||
|
||||
Returns:
|
||||
True if comment exists, False otherwise.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentORM).where(CommentORM.id == str(entity_id))
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_by_post(self, post_id: UUID) -> list[Comment]:
|
||||
"""Get all comments for a post, ordered by creation time.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
List of Comment entities for the post.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentORM)
|
||||
.where(CommentORM.post_id == str(post_id))
|
||||
.order_by(CommentORM.created_at.asc())
|
||||
)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def count_by_post(self, post_id: UUID) -> int:
|
||||
"""Get comment count for a post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
|
||||
Returns:
|
||||
Number of comments on the post.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(func.count()).select_from(CommentORM).where(CommentORM.post_id == str(post_id))
|
||||
)
|
||||
count: int = result.scalar() or 0
|
||||
return count
|
||||
|
||||
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
|
||||
"""Get a like by comment and user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
|
||||
Returns:
|
||||
CommentLike if found, None otherwise.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentLikeORM).where(
|
||||
CommentLikeORM.comment_id == str(comment_id),
|
||||
CommentLikeORM.liked_by == liked_by,
|
||||
)
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if not orm:
|
||||
return None
|
||||
return CommentLike(
|
||||
id=UUID(orm.id),
|
||||
comment_id=UUID(orm.comment_id),
|
||||
liked_by=orm.liked_by,
|
||||
created_at=orm.created_at,
|
||||
)
|
||||
|
||||
async def add_like(self, like: CommentLike) -> None:
|
||||
"""Add a new like to a comment.
|
||||
|
||||
Args:
|
||||
like: CommentLike entity to add.
|
||||
"""
|
||||
orm = CommentLikeORM(
|
||||
id=str(like.id),
|
||||
comment_id=str(like.comment_id),
|
||||
liked_by=like.liked_by,
|
||||
created_at=like.created_at,
|
||||
)
|
||||
self._session.add(orm)
|
||||
|
||||
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
|
||||
"""Remove a like from a comment by user.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
liked_by: User ID.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
select(CommentLikeORM).where(
|
||||
CommentLikeORM.comment_id == str(comment_id),
|
||||
CommentLikeORM.liked_by == liked_by,
|
||||
)
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if orm:
|
||||
await self._session.delete(orm)
|
||||
@@ -11,11 +11,15 @@ from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.application import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeleteCommentUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -31,6 +35,11 @@ ListPostsDep = FromDishka[ListPostsUseCase]
|
||||
PublishPostDep = FromDishka[PublishPostUseCase]
|
||||
ToggleLikeDep = FromDishka[TogglePostLikeUseCase]
|
||||
|
||||
CreateCommentDep = FromDishka[CreateCommentUseCase]
|
||||
DeleteCommentDep = FromDishka[DeleteCommentUseCase]
|
||||
ListCommentsDep = FromDishka[ListCommentsUseCase]
|
||||
ToggleCommentLikeDep = FromDishka[ToggleCommentLikeUseCase]
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ all v1 endpoint routers.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.presentation.api.v1.comments import router as comments_router
|
||||
from app.presentation.api.v1.posts import router as posts_router
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
router.include_router(posts_router)
|
||||
router.include_router(comments_router)
|
||||
|
||||
131
app/presentation/api/v1/comments.py
Normal file
131
app/presentation/api/v1/comments.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Comments API routes.
|
||||
|
||||
This module defines FastAPI routes for comment operations including
|
||||
CRUD and like/unlike toggle.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from dishka.integrations.fastapi import DishkaRoute
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.presentation.api.deps import (
|
||||
CreateCommentDep,
|
||||
CurrentRoleDep,
|
||||
CurrentUserDep,
|
||||
DeleteCommentDep,
|
||||
ListCommentsDep,
|
||||
ToggleCommentLikeDep,
|
||||
)
|
||||
from app.presentation.schemas import (
|
||||
CommentCreateSchema,
|
||||
CommentLikeResponseSchema,
|
||||
CommentResponseSchema,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["comments"], route_class=DishkaRoute)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/posts/{post_id}/comments",
|
||||
response_model=CommentResponseSchema,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a comment on a post",
|
||||
)
|
||||
async def create_comment(
|
||||
post_id: UUID,
|
||||
schema: CommentCreateSchema,
|
||||
use_case: CreateCommentDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
) -> CommentResponseSchema:
|
||||
"""Create a comment on a blog post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post to comment on.
|
||||
schema: Comment creation data.
|
||||
use_case: CreateCommentUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
|
||||
Returns:
|
||||
CommentResponseSchema with created comment data.
|
||||
"""
|
||||
result = await use_case.execute(
|
||||
post_id=post_id,
|
||||
author_id=current_user_id,
|
||||
content=schema.content,
|
||||
parent_id=schema.parent_id,
|
||||
)
|
||||
return CommentResponseSchema(**result.__dict__)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/posts/{post_id}/comments",
|
||||
response_model=list[CommentResponseSchema],
|
||||
summary="List comments for a post",
|
||||
)
|
||||
async def list_comments(
|
||||
post_id: UUID,
|
||||
use_case: ListCommentsDep,
|
||||
) -> list[CommentResponseSchema]:
|
||||
"""Get all comments for a blog post.
|
||||
|
||||
Args:
|
||||
post_id: UUID of the post.
|
||||
use_case: ListCommentsUseCase dependency.
|
||||
|
||||
Returns:
|
||||
List of CommentResponseSchema for the post.
|
||||
"""
|
||||
results = await use_case.execute(post_id=post_id)
|
||||
return [CommentResponseSchema(**r.__dict__) for r in results]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/comments/{comment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a comment",
|
||||
)
|
||||
async def delete_comment(
|
||||
comment_id: UUID,
|
||||
use_case: DeleteCommentDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
role: CurrentRoleDep,
|
||||
) -> None:
|
||||
"""Delete a comment.
|
||||
|
||||
Users can delete their own comments.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment to delete.
|
||||
use_case: DeleteCommentUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
role: Current user role.
|
||||
"""
|
||||
await use_case.execute(comment_id=comment_id, user_id=current_user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/comments/{comment_id}/like",
|
||||
response_model=CommentLikeResponseSchema,
|
||||
summary="Toggle like on a comment",
|
||||
)
|
||||
async def toggle_comment_like(
|
||||
comment_id: UUID,
|
||||
use_case: ToggleCommentLikeDep,
|
||||
current_user_id: CurrentUserDep,
|
||||
) -> CommentLikeResponseSchema:
|
||||
"""Toggle like/unlike on a comment.
|
||||
|
||||
If the user already liked the comment, the like is removed (unlike).
|
||||
Otherwise, a new like is added.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
use_case: ToggleCommentLikeUseCase dependency.
|
||||
current_user_id: Authenticated user ID.
|
||||
|
||||
Returns:
|
||||
CommentLikeResponseSchema with updated like_count.
|
||||
"""
|
||||
result = await use_case.execute(comment_id, current_user_id)
|
||||
return CommentLikeResponseSchema(id=result.id, like_count=result.like_count)
|
||||
@@ -4,6 +4,11 @@ This module re-exports all Pydantic schemas used for
|
||||
request/response validation in the API layer.
|
||||
"""
|
||||
|
||||
from app.presentation.schemas.comment import (
|
||||
CommentCreateSchema,
|
||||
CommentLikeResponseSchema,
|
||||
CommentResponseSchema,
|
||||
)
|
||||
from app.presentation.schemas.post import (
|
||||
PostBaseSchema,
|
||||
PostCreateSchema,
|
||||
@@ -22,4 +27,7 @@ __all__ = [
|
||||
"PostListResponseSchema",
|
||||
"PostSearchSchema",
|
||||
"PostPublishSchema",
|
||||
"CommentCreateSchema",
|
||||
"CommentResponseSchema",
|
||||
"CommentLikeResponseSchema",
|
||||
]
|
||||
|
||||
58
app/presentation/schemas/comment.py
Normal file
58
app/presentation/schemas/comment.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Pydantic schemas for comments.
|
||||
|
||||
This module defines Pydantic models for comment request/response
|
||||
validation in the API layer.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CommentCreateSchema(BaseModel):
|
||||
"""Schema for creating a comment.
|
||||
|
||||
Attributes:
|
||||
content: Comment text content (Markdown supported).
|
||||
parent_id: Optional parent comment ID for replies.
|
||||
"""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=5000, description="Comment content")
|
||||
parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies")
|
||||
|
||||
|
||||
class CommentResponseSchema(BaseModel):
|
||||
"""Schema for comment response.
|
||||
|
||||
Attributes:
|
||||
id: Unique comment identifier.
|
||||
post_id: UUID of the parent post.
|
||||
author_id: Comment author identifier.
|
||||
content: Comment content text.
|
||||
parent_id: Optional parent comment ID.
|
||||
like_count: Number of likes on this comment.
|
||||
created_at: Creation timestamp.
|
||||
updated_at: Last update timestamp.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
post_id: UUID
|
||||
author_id: str
|
||||
content: str
|
||||
parent_id: UUID | None = None
|
||||
like_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CommentLikeResponseSchema(BaseModel):
|
||||
"""Schema for comment like response.
|
||||
|
||||
Attributes:
|
||||
id: Comment identifier.
|
||||
like_count: Updated like count.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
like_count: int
|
||||
@@ -55,6 +55,9 @@
|
||||
<span class="post-card-meta-item" data-testid="like-count-{{ post.id }}">
|
||||
👍 {{ post.like_count }}
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="comment-count-{{ post.id }}">
|
||||
💬 {{ post.comment_count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<article class="post-detail" data-testid="post-detail">
|
||||
<header class="post-detail-header" data-testid="post-detail-header">
|
||||
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
|
||||
|
||||
|
||||
<div class="post-detail-meta" data-testid="post-detail-meta">
|
||||
<span class="post-card-meta-item" data-testid="post-detail-author">
|
||||
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
|
||||
@@ -40,22 +40,25 @@
|
||||
👍 <span id="like-count">{{ post.like_count }}</span>
|
||||
</button>
|
||||
</span>
|
||||
<span class="post-card-meta-item" data-testid="post-detail-comment-count">
|
||||
💬 {{ post.comment_count }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<div class="post-detail-content markdown-body" data-testid="post-detail-content">
|
||||
{{ post.content|markdown|safe }}
|
||||
</div>
|
||||
|
||||
|
||||
<footer class="post-detail-footer" data-testid="post-detail-footer">
|
||||
<div class="post-detail-tags" data-testid="post-detail-tags">
|
||||
{% for tag in post.tags %}
|
||||
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="divider" data-testid="post-detail-divider"></div>
|
||||
|
||||
|
||||
<div class="flex justify-between items-center" data-testid="post-detail-actions">
|
||||
<a href="/" class="btn" data-testid="btn-back-to-posts">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
@@ -63,7 +66,7 @@
|
||||
</svg>
|
||||
{{ _('post.back_to_posts', current_locale) }}
|
||||
</a>
|
||||
|
||||
|
||||
{% if can_edit or can_delete %}
|
||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||
{% if can_edit %}
|
||||
@@ -89,41 +92,267 @@
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<section class="comments-section" data-testid="comments-section">
|
||||
<div class="comments-header" data-testid="comments-header">
|
||||
<h2 class="comments-title" data-testid="comments-title">
|
||||
💬 {{ _('post.comments', current_locale) }}
|
||||
<span class="comments-count" data-testid="comments-count">({{ post.comment_count }})</span>
|
||||
</h2>
|
||||
{% if user %}
|
||||
<button id="btn-show-comment-form" class="btn btn-primary" data-testid="btn-show-comment-form">
|
||||
{{ _('post.write_comment', current_locale) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div id="comment-form-wrapper" class="comment-form-wrapper" data-testid="comment-form-wrapper" style="display: none;">
|
||||
<form id="comment-form" class="comment-form" data-testid="form-create-comment" data-post-slug="{{ post.slug }}">
|
||||
<div class="form-group">
|
||||
<textarea id="comment-content" class="form-textarea" data-testid="input-comment-content"
|
||||
rows="4" placeholder="{{ _('post.comment_placeholder', current_locale) }}"
|
||||
required minlength="1" maxlength="5000"></textarea>
|
||||
<input type="hidden" id="comment-parent-id" name="parent_id" value="">
|
||||
<p class="form-help" data-testid="comment-form-help" id="reply-info" style="display: none;">
|
||||
{{ _('post.replying_to', current_locale) }}
|
||||
<button type="button" class="btn-cancel-reply" data-testid="btn-cancel-reply">{{ _('post.cancel_reply', current_locale) }}</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" data-testid="submit-comment">
|
||||
{{ _('post.submit_comment', current_locale) }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-cancel-comment" data-testid="btn-cancel-comment" style="display: none;">
|
||||
{{ _('post.cancel', current_locale) }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="comment-error" class="comment-error" data-testid="comment-error" style="display: none;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% macro render_comment(comment, depth) %}
|
||||
<div class="comment{% if depth > 0 %} comment-reply{% endif %}" data-testid="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
|
||||
<div class="comment-avatar" data-testid="comment-avatar-{{ comment.id }}">
|
||||
<span class="avatar avatar-sm">{{ comment.author_id[0]|upper }}</span>
|
||||
</div>
|
||||
<div class="comment-body" data-testid="comment-body-{{ comment.id }}">
|
||||
<div class="comment-meta" data-testid="comment-meta-{{ comment.id }}">
|
||||
<span class="comment-author" data-testid="comment-author-{{ comment.id }}">{{ comment.author_id }}</span>
|
||||
<span class="comment-date" data-testid="comment-date-{{ comment.id }}">
|
||||
{% if comment.created_at %}{{ comment.created_at.strftime('%B %d, %Y') }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="comment-content" data-testid="comment-content-{{ comment.id }}">{{ comment.content }}</div>
|
||||
<div class="comment-actions" data-testid="comment-actions-{{ comment.id }}">
|
||||
{% if user %}
|
||||
<button class="btn-comment-reply btn btn-sm" data-testid="btn-comment-reply-{{ comment.id }}"
|
||||
data-comment-id="{{ comment.id }}" data-comment-author="{{ comment.author_id }}">
|
||||
{{ _('post.reply', current_locale) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn-comment-like btn btn-sm" data-testid="btn-comment-like-{{ comment.id }}"
|
||||
data-comment-id="{{ comment.id }}">
|
||||
👍 <span class="comment-like-count" data-testid="comment-like-count-{{ comment.id }}">{{ comment.like_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% set key = comment.id|string %}
|
||||
{% if key in reply_comments %}
|
||||
<div class="comment-replies" data-testid="comment-replies-{{ comment.id }}">
|
||||
{% for child in reply_comments[key] %}
|
||||
{{ render_comment(child, depth + 1) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="comments-list" data-testid="comments-list">
|
||||
{% if top_level_comments %}
|
||||
{% for comment in top_level_comments %}
|
||||
{{ render_comment(comment, 0) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="comments-empty" data-testid="comments-empty">
|
||||
<p>{{ _('post.no_comments', current_locale) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script data-testid="like-script">
|
||||
<script data-testid="comment-script">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var likeButton = document.getElementById('like-button');
|
||||
if (!likeButton) return;
|
||||
if (likeButton) {
|
||||
likeButton.addEventListener('click', function() {
|
||||
var slug = this.getAttribute('data-post-slug');
|
||||
var countSpan = document.getElementById('like-count');
|
||||
|
||||
likeButton.addEventListener('click', function() {
|
||||
var slug = this.getAttribute('data-post-slug');
|
||||
var countSpan = document.getElementById('like-count');
|
||||
fetch('/web/posts/' + slug + '/like', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/dev-login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Like request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.like_count !== undefined) {
|
||||
countSpan.textContent = data.like_count;
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Like error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/web/posts/' + slug + '/like', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
var showFormBtn = document.getElementById('btn-show-comment-form');
|
||||
var formWrapper = document.getElementById('comment-form-wrapper');
|
||||
var cancelBtn = document.querySelector('.btn-cancel-comment');
|
||||
var commentForm = document.getElementById('comment-form');
|
||||
var commentContent = document.getElementById('comment-content');
|
||||
var commentParentId = document.getElementById('comment-parent-id');
|
||||
var replyInfo = document.getElementById('reply-info');
|
||||
var commentError = document.getElementById('comment-error');
|
||||
|
||||
function showCommentForm(parentId, authorName) {
|
||||
commentParentId.value = parentId || '';
|
||||
if (parentId && authorName) {
|
||||
replyInfo.style.display = 'block';
|
||||
replyInfo.innerHTML = '{{ _("post.replying_to", current_locale) }} <strong>' + authorName + '</strong> — <button type="button" class="btn-cancel-reply" id="btn-cancel-reply">{{ _("post.cancel_reply", current_locale) }}</button>';
|
||||
document.getElementById('btn-cancel-reply').addEventListener('click', function() {
|
||||
commentParentId.value = '';
|
||||
replyInfo.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
replyInfo.style.display = 'none';
|
||||
}
|
||||
formWrapper.style.display = 'block';
|
||||
if (showFormBtn) showFormBtn.style.display = 'none';
|
||||
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
|
||||
commentContent.focus();
|
||||
commentError.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideCommentForm() {
|
||||
formWrapper.style.display = 'none';
|
||||
if (showFormBtn) showFormBtn.style.display = 'inline-flex';
|
||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||
commentContent.value = '';
|
||||
commentParentId.value = '';
|
||||
replyInfo.style.display = 'none';
|
||||
commentError.style.display = 'none';
|
||||
}
|
||||
|
||||
if (showFormBtn) {
|
||||
showFormBtn.addEventListener('click', function() {
|
||||
showCommentForm(null, null);
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', hideCommentForm);
|
||||
}
|
||||
|
||||
if (commentForm) {
|
||||
commentForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var content = commentContent.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
var slug = this.getAttribute('data-post-slug');
|
||||
var parentId = commentParentId.value || null;
|
||||
|
||||
var payload = {content: content};
|
||||
if (parentId) {
|
||||
payload.parent_id = parentId;
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/dev-login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Like request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.like_count !== undefined) {
|
||||
countSpan.textContent = data.like_count;
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Like error:', error);
|
||||
|
||||
fetch('/web/posts/' + slug + '/comments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/dev-login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Comment creation failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
commentError.textContent = '{{ _("post.comment_error", current_locale) }}';
|
||||
commentError.style.display = 'block';
|
||||
console.error('Comment error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var replyButtons = document.querySelectorAll('.btn-comment-reply');
|
||||
replyButtons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var commentId = this.getAttribute('data-comment-id');
|
||||
var author = this.getAttribute('data-comment-author');
|
||||
showCommentForm(commentId, author);
|
||||
});
|
||||
});
|
||||
|
||||
var commentLikeButtons = document.querySelectorAll('.btn-comment-like');
|
||||
commentLikeButtons.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var commentId = this.getAttribute('data-comment-id');
|
||||
var countSpan = this.querySelector('.comment-like-count');
|
||||
|
||||
fetch('/web/comments/' + commentId + '/like', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/dev-login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Comment like failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.like_count !== undefined) {
|
||||
countSpan.textContent = data.like_count;
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Comment like error:', error);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ integration with the application's use cases and domain layer.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -19,11 +20,14 @@ from pygments.util import ClassNotFound
|
||||
|
||||
from app.application.dtos import CreatePostDTO, UpdatePostDTO
|
||||
from app.application.use_cases import (
|
||||
CreateCommentUseCase,
|
||||
CreatePostUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListCommentsUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
ToggleCommentLikeUseCase,
|
||||
TogglePostLikeUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
@@ -32,6 +36,7 @@ from app.domain.exceptions import (
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import CommentRepository
|
||||
from app.domain.roles import Role, get_effective_role
|
||||
from app.infrastructure.auth import TokenInfo
|
||||
from app.infrastructure.config.settings import settings
|
||||
@@ -177,6 +182,7 @@ async def home(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the home page with list of posts.
|
||||
|
||||
@@ -184,10 +190,13 @@ async def home(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -196,6 +205,10 @@ async def home(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -217,6 +230,7 @@ async def list_posts(
|
||||
request: Request,
|
||||
user: OptionalUserDep,
|
||||
list_use_case: FromDishka[ListPostsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render the posts listing page.
|
||||
|
||||
@@ -224,10 +238,13 @@ async def list_posts(
|
||||
request: The HTTP request object for template context.
|
||||
user: Current user from dependency.
|
||||
list_use_case: Use case for listing posts.
|
||||
comment_repo: Repository for fetching comment counts.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered posts list template.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
page_str = request.query_params.get("page", "1")
|
||||
page = max(1, int(page_str) if page_str.isdigit() else 1)
|
||||
offset = (page - 1) * _DEFAULT_PAGE_SIZE
|
||||
@@ -236,6 +253,10 @@ async def list_posts(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
for i, post in enumerate(visible_posts):
|
||||
count = await comment_repo.count_by_post(post.id)
|
||||
visible_posts[i] = replace(post, comment_count=count)
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
@@ -339,14 +360,18 @@ async def post_detail(
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
list_comments_use_case: FromDishka[ListCommentsUseCase],
|
||||
comment_repo: FromDishka[CommentRepository],
|
||||
) -> HTMLResponse:
|
||||
"""Render a single post detail page.
|
||||
"""Render a single post detail page with comments.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object for template context.
|
||||
post_slug: The URL-friendly slug of the post to display.
|
||||
user: Current user from dependency.
|
||||
get_use_case: Use case for retrieving posts.
|
||||
list_comments_use_case: Use case for listing comments.
|
||||
comment_repo: Repository for fetching comment count.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with rendered post detail template.
|
||||
@@ -354,6 +379,8 @@ async def post_detail(
|
||||
Raises:
|
||||
HTTPException: If post not found or not visible to user.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
@@ -362,6 +389,19 @@ async def post_detail(
|
||||
if not post.published and not can_see_draft(user, post.author_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
comments = await list_comments_use_case.execute(post.id)
|
||||
comment_count = await comment_repo.count_by_post(post.id)
|
||||
post = replace(post, comment_count=comment_count)
|
||||
|
||||
children: dict[str, list[Any]] = {}
|
||||
for c in comments:
|
||||
pid = str(c.parent_id) if c.parent_id else ""
|
||||
if pid not in children:
|
||||
children[pid] = []
|
||||
children[pid].append(c)
|
||||
|
||||
top_level = children.pop("", [])
|
||||
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
@@ -371,6 +411,8 @@ async def post_detail(
|
||||
{
|
||||
**context,
|
||||
"post": post,
|
||||
"top_level_comments": top_level,
|
||||
"reply_comments": children,
|
||||
"active_page": "posts",
|
||||
"can_edit": can_edit_post(user, post.author_id),
|
||||
"can_delete": can_delete_post(user, post.author_id),
|
||||
@@ -378,6 +420,89 @@ async def post_detail(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/posts/{post_slug}/comments")
|
||||
async def create_comment_web(
|
||||
request: Request,
|
||||
post_slug: str,
|
||||
user: OptionalUserDep,
|
||||
get_use_case: FromDishka[GetPostUseCase],
|
||||
create_use_case: FromDishka[CreateCommentUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Create a comment on a post via web UI.
|
||||
|
||||
Args:
|
||||
request: The HTTP request object with JSON body.
|
||||
post_slug: The URL-friendly slug of the post.
|
||||
user: Current user from cookie or None.
|
||||
get_use_case: Use case for retrieving post.
|
||||
create_use_case: Use case for creating comments.
|
||||
|
||||
Returns:
|
||||
JSON dict with created comment data.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated or post not found.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
post = await get_use_case.by_slug(post_slug)
|
||||
except NotFoundException:
|
||||
raise HTTPException(status_code=404, detail="Post not found") from None
|
||||
|
||||
body = await request.json()
|
||||
content = str(body.get("content", "")).strip()
|
||||
parent_id_str = body.get("parent_id")
|
||||
|
||||
parent_id: UUID | None = None
|
||||
if parent_id_str:
|
||||
parent_id = UUID(parent_id_str)
|
||||
|
||||
result = await create_use_case.execute(
|
||||
post_id=post.id,
|
||||
author_id=user.user_id,
|
||||
content=content,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(result.id),
|
||||
"post_id": str(result.post_id),
|
||||
"author_id": result.author_id,
|
||||
"content": result.content,
|
||||
"parent_id": str(result.parent_id) if result.parent_id else None,
|
||||
"like_count": result.like_count,
|
||||
"created_at": result.created_at.isoformat() if result.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/like")
|
||||
async def toggle_comment_like_web(
|
||||
comment_id: UUID,
|
||||
user: OptionalUserDep,
|
||||
toggle_use_case: FromDishka[ToggleCommentLikeUseCase],
|
||||
) -> dict[str, object]:
|
||||
"""Toggle like on a comment via web UI.
|
||||
|
||||
Args:
|
||||
comment_id: UUID of the comment.
|
||||
user: Current user from cookie or None.
|
||||
toggle_use_case: Use case for toggling comment likes.
|
||||
|
||||
Returns:
|
||||
JSON dict with updated like_count.
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
result = await toggle_use_case.execute(comment_id, user.user_id)
|
||||
return {"like_count": result.like_count, "id": str(result.id)}
|
||||
|
||||
|
||||
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
|
||||
async def edit_post_form(
|
||||
request: Request,
|
||||
|
||||
@@ -362,3 +362,179 @@ input[type="checkbox"] {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Comment section */
|
||||
.comments-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.comments-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comments-count {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-light-3);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.comment-form-wrapper {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.comment-form .form-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-input-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-alpha-30);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-light-3);
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.comment-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--color-error-bg);
|
||||
border: 1px solid var(--color-error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-error-text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Individual comment */
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.comment + .comment {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.375rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-comment-reply,
|
||||
.btn-comment-like {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comment-replies {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comment-reply {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.comment-reply + .comment-reply {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comments-empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--color-text-light-3);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-cancel-reply {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-cancel-reply:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
285
tests/FEATURE_COMMENTS.md
Normal file
285
tests/FEATURE_COMMENTS.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Test Model: Comments
|
||||
|
||||
Feature: Add comments to blog posts with Markdown editor, nested replies
|
||||
(parent_id), and per-comment like/unlike toggle.
|
||||
|
||||
## Unit Test Cases
|
||||
|
||||
### Domain Entities
|
||||
|
||||
#### TC-UNIT-829: Comment entity — valid creation
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/domain/test_comment_entity.py::TestCommentEntity::test_comment_creation`
|
||||
- **Preconditions:** Valid post_id, author_id, and content
|
||||
- **Steps:** Create Comment instance
|
||||
- **Expected:**
|
||||
- `post_id` matches input
|
||||
- `author_id` matches input
|
||||
- `content` is CommentContent value object
|
||||
- `id` is a valid UUID
|
||||
- `parent_id` is None
|
||||
- `like_count` is 0
|
||||
- `created_at` is set
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-830: Comment entity — with parent_id (reply)
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/domain/test_comment_entity.py::TestCommentEntity::test_comment_with_parent`
|
||||
- **Preconditions:** Valid post_id, author_id, content, and parent_id
|
||||
- **Steps:** Create Comment instance with parent_id
|
||||
- **Expected:**
|
||||
- `parent_id` matches input
|
||||
- All other attributes set correctly
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-831: CommentLike entity — valid creation
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/domain/test_comment_like_entity.py::TestCommentLikeEntity::test_comment_like_creation`
|
||||
- **Preconditions:** Valid comment_id and liked_by
|
||||
- **Steps:** Create CommentLike instance
|
||||
- **Expected:**
|
||||
- `comment_id` matches input
|
||||
- `liked_by` matches input
|
||||
- `id` is a valid UUID
|
||||
- `created_at` is set
|
||||
- **Last Verified:** —
|
||||
|
||||
### CreateCommentUseCase
|
||||
|
||||
#### TC-UNIT-832: CreateCommentUseCase — on post (top-level)
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_on_post`
|
||||
- **Preconditions:** Post exists
|
||||
- **Steps:** Execute with post_id, author_id, content, parent_id=None
|
||||
- **Expected:**
|
||||
- Comment created with correct post_id and author_id
|
||||
- `parent_id` is None
|
||||
- `comment_repo.add` called once
|
||||
- Transaction committed
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-833: CreateCommentUseCase — reply to comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_reply`
|
||||
- **Preconditions:** Post and parent comment exist
|
||||
- **Steps:** Execute with post_id, author_id, content, parent_id set
|
||||
- **Expected:**
|
||||
- Comment created with correct parent_id
|
||||
- `comment_repo.add` called once
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-834: CreateCommentUseCase — post not found
|
||||
- **Type:** Negative
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_post_not_found`
|
||||
- **Preconditions:** Post does not exist
|
||||
- **Steps:** Execute with non-existent post_id
|
||||
- **Expected:** `NotFoundException` raised
|
||||
- **Last Verified:** —
|
||||
|
||||
### ListCommentsUseCase
|
||||
|
||||
#### TC-UNIT-835: ListCommentsUseCase — returns comments for post
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_list_comments.py::TestListCommentsUseCase::test_list_comments_by_post`
|
||||
- **Preconditions:** Post has multiple comments and replies
|
||||
- **Steps:** Execute with post_id
|
||||
- **Expected:** Returns list of CommentResponseDTO with correct post_id
|
||||
- **Last Verified:** —
|
||||
|
||||
### DeleteCommentUseCase
|
||||
|
||||
#### TC-UNIT-836: DeleteCommentUseCase — own comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_delete_comment.py::TestDeleteCommentUseCase::test_delete_own_comment`
|
||||
- **Preconditions:** Comment exists owned by user
|
||||
- **Steps:** Execute with comment_id, user_id matching author_id
|
||||
- **Expected:** `comment_repo.delete` called
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-837: DeleteCommentUseCase — not found
|
||||
- **Type:** Negative
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_delete_comment.py::TestDeleteCommentUseCase::test_delete_comment_not_found`
|
||||
- **Preconditions:** Comment does not exist
|
||||
- **Steps:** Execute with non-existent comment_id
|
||||
- **Expected:** `NotFoundException` raised
|
||||
- **Last Verified:** —
|
||||
|
||||
### ToggleCommentLikeUseCase
|
||||
|
||||
#### TC-UNIT-838: ToggleCommentLikeUseCase — like first time
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_like_comment_first_time`
|
||||
- **Preconditions:** Comment exists, no existing like for this user
|
||||
- **Steps:** Execute toggle with comment_id and liked_by
|
||||
- **Expected:**
|
||||
- `add_like` called once
|
||||
- `remove_like` not called
|
||||
- Response DTO has `like_count=1`
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-839: ToggleCommentLikeUseCase — unlike (already liked)
|
||||
- **Type:** Positive
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_unlike_comment_already_liked`
|
||||
- **Preconditions:** Comment exists, existing like found for this user
|
||||
- **Steps:** Execute toggle with same comment_id and liked_by
|
||||
- **Expected:**
|
||||
- `remove_like` called once
|
||||
- `add_like` not called
|
||||
- Response DTO has `like_count=0`
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-UNIT-840: ToggleCommentLikeUseCase — comment not found
|
||||
- **Type:** Negative
|
||||
- **Layer:** Unit
|
||||
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_like_comment_not_found`
|
||||
- **Preconditions:** Comment does not exist
|
||||
- **Steps:** Execute toggle with non-existent comment_id
|
||||
- **Expected:** `NotFoundException` raised
|
||||
- **Last Verified:** —
|
||||
|
||||
## API Test Cases
|
||||
|
||||
#### TC-API-118: Create comment — authenticated
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_authenticated`
|
||||
- **Preconditions:** Post exists, user authenticated
|
||||
- **Steps:** POST `/api/v1/posts/{post_id}/comments` with auth header
|
||||
- **Expected:**
|
||||
- Status 201
|
||||
- Response has comment_id, post_id, content, author_id
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-119: Create comment — reply to comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_reply`
|
||||
- **Preconditions:** Post and comment exist, user authenticated
|
||||
- **Steps:** POST with `parent_id` set to existing comment
|
||||
- **Expected:**
|
||||
- Status 201
|
||||
- Response has correct `parent_id`
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-120: Create comment — guest
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_as_guest`
|
||||
- **Preconditions:** Post exists, guest token used
|
||||
- **Steps:** POST without auth header
|
||||
- **Expected:** Status 401
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-121: List comments — by post
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestListComments::test_list_comments_by_post`
|
||||
- **Preconditions:** Post exists with comments
|
||||
- **Steps:** GET `/api/v1/posts/{post_id}/comments`
|
||||
- **Expected:**
|
||||
- Status 200
|
||||
- Response contains list of comments
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-122: Delete comment — own comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestDeleteComment::test_delete_own_comment`
|
||||
- **Preconditions:** Comment exists owned by authenticated user
|
||||
- **Steps:** DELETE `/api/v1/comments/{comment_id}` with auth header
|
||||
- **Expected:** Status 204
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-123: Delete comment — not owner
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestDeleteComment::test_delete_comment_not_owner`
|
||||
- **Preconditions:** Comment exists owned by different user
|
||||
- **Steps:** DELETE with another user's auth header
|
||||
- **Expected:** Status 403
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-124: Toggle comment like — authenticated
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestLikeComment::test_like_comment_authenticated`
|
||||
- **Preconditions:** Comment exists, user authenticated
|
||||
- **Steps:** POST `/api/v1/comments/{comment_id}/like` with auth header
|
||||
- **Expected:**
|
||||
- Status 200
|
||||
- `like_count == 1`
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-API-125: Toggle comment like — guest
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_comments.py::TestLikeComment::test_like_comment_as_guest`
|
||||
- **Preconditions:** Comment exists, guest token used
|
||||
- **Steps:** POST without auth header
|
||||
- **Expected:** Status 401
|
||||
- **Last Verified:** —
|
||||
|
||||
## E2E Test Cases
|
||||
|
||||
#### TC-E2E-109: Create comment via web UI
|
||||
- **Type:** Positive
|
||||
- **Layer:** E2E
|
||||
- **File:** `tests/e2e/test_comments.py::test_create_comment`
|
||||
- **Scenario:** Login → open post → write comment with Markdown → verify display
|
||||
- **Expected:** Comment displayed on post detail page with rendered Markdown
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-E2E-110: Reply to comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** E2E
|
||||
- **File:** `tests/e2e/test_comments.py::test_reply_to_comment`
|
||||
- **Scenario:** Create top-level comment → click Reply → write reply → verify nesting
|
||||
- **Expected:** Reply appears below parent comment with indentation
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-E2E-111: Like/unlike comment
|
||||
- **Type:** Positive
|
||||
- **Layer:** E2E
|
||||
- **File:** `tests/e2e/test_comments.py::test_like_unlike_comment`
|
||||
- **Scenario:** Create comment → like → verify count → unlike → verify count
|
||||
- **Expected:** Count toggles correctly (0→1→0)
|
||||
- **Last Verified:** —
|
||||
|
||||
#### TC-E2E-112: Guest cannot comment
|
||||
- **Type:** Negative
|
||||
- **Layer:** E2E
|
||||
- **File:** `tests/e2e/test_comments.py::test_guest_cannot_comment`
|
||||
- **Scenario:** Guest opens published post → comment form not visible → cannot post
|
||||
- **Expected:** Comment form hidden for guests
|
||||
- **Last Verified:** —
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Component | Cases | Status |
|
||||
|-----------|-------|--------|
|
||||
| Domain Entities (Comment, CommentLike) | 3 | ⬜ Planned |
|
||||
| CreateCommentUseCase | 3 | ⬜ Planned |
|
||||
| ListCommentsUseCase | 1 | ⬜ Planned |
|
||||
| DeleteCommentUseCase | 2 | ⬜ Planned |
|
||||
| ToggleCommentLikeUseCase | 3 | ⬜ Planned |
|
||||
| API Endpoints | 8 | ⬜ Planned |
|
||||
| E2E Flows | 4 | ⬜ Planned |
|
||||
|
||||
## Gaps (Not Yet Covered)
|
||||
|
||||
- [ ] Integration tests for SQLAlchemyCommentRepository
|
||||
- [ ] Web-only tests (TC-WEB-004+)
|
||||
- [ ] Admin delete any comment
|
||||
- [ ] Edit comment
|
||||
- [ ] Comment pagination with large number of comments
|
||||
@@ -23,6 +23,7 @@ adding new tests.
|
||||
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
||||
| i18n Localization | 100% | — | — | — | P1 | ✅ Active |
|
||||
| Post Likes | 100% | — | 100% | — | P1 | ✅ Active |
|
||||
| Comments (CRUD, Like, Nested) | — | — | — | — | P1 | 🔴 In Progress |
|
||||
|
||||
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||
|
||||
@@ -36,6 +37,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||
| i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||
| Post Likes | [FEATURE_LIKES.md](FEATURE_LIKES.md) |
|
||||
| Comments | [FEATURE_COMMENTS.md](FEATURE_COMMENTS.md) |
|
||||
|
||||
## Test Naming Convention
|
||||
|
||||
|
||||
239
tests/api/test_comments.py
Normal file
239
tests/api/test_comments.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""API tests for blog post comments.
|
||||
|
||||
This module tests comment CRUD operations, nested replies,
|
||||
and comment like/unlike toggle via API endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.api.conftest import API_PREFIX
|
||||
|
||||
COMMENT_CONTENT = "This is a test comment with enough length."
|
||||
|
||||
|
||||
class TestCreateComment:
|
||||
"""Tests for POST /api/v1/posts/{post_id}/comments — create a comment."""
|
||||
|
||||
def test_create_comment_authenticated(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a comment as authenticated user.
|
||||
|
||||
TC-API-118: Positive — create top-level comment.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["post_id"] == post_id
|
||||
assert data["author_id"] == "dev-user"
|
||||
assert data["content"] == COMMENT_CONTENT
|
||||
assert data["parent_id"] is None
|
||||
assert data["like_count"] == 0
|
||||
assert UUID(data["id"])
|
||||
|
||||
def test_create_comment_reply(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a reply to an existing comment.
|
||||
|
||||
TC-API-119: Positive — reply to comment with parent_id.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
parent = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
assert parent.status_code == 201
|
||||
parent_id = parent.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": "Reply to comment.", "parent_id": parent_id},
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["parent_id"] == parent_id
|
||||
assert data["post_id"] == post_id
|
||||
|
||||
def test_create_comment_as_guest(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a comment as guest returns 401.
|
||||
|
||||
TC-API-120: Negative — guest cannot create comment.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestListComments:
|
||||
"""Tests for GET /api/v1/posts/{post_id}/comments — list comments."""
|
||||
|
||||
def test_list_comments_by_post(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test listing comments for a post.
|
||||
|
||||
TC-API-121: Positive — returns list of comments.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": "Second comment."},
|
||||
headers=user_headers,
|
||||
)
|
||||
|
||||
response = client.get(f"{API_PREFIX}/{post_id}/comments")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["content"] == COMMENT_CONTENT
|
||||
assert data[1]["content"] == "Second comment."
|
||||
|
||||
|
||||
class TestDeleteComment:
|
||||
"""Tests for DELETE /api/v1/comments/{comment_id} — delete a comment."""
|
||||
|
||||
def test_delete_own_comment(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting own comment returns 204.
|
||||
|
||||
TC-API-122: Positive — delete own comment.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
comment_resp = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
comment_id = comment_resp.json()["id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/comments/{comment_id}",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_delete_comment_not_owner(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting another user's comment returns 403.
|
||||
|
||||
TC-API-123: Negative — not owner.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
comment_resp = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
comment_id = comment_resp.json()["id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/comments/{comment_id}",
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestLikeComment:
|
||||
"""Tests for POST /api/v1/comments/{comment_id}/like — like a comment."""
|
||||
|
||||
def test_like_comment_authenticated(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test liking a comment as authenticated user.
|
||||
|
||||
TC-API-124: Positive — authenticated like on.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
comment_resp = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
comment_id = comment_resp.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/comments/{comment_id}/like",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["like_count"] == 1
|
||||
assert data["id"] == comment_id
|
||||
|
||||
def test_like_comment_as_guest(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test liking a comment as guest returns 401.
|
||||
|
||||
TC-API-125: Negative — guest cannot like.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
comment_resp = client.post(
|
||||
f"{API_PREFIX}/{post_id}/comments",
|
||||
json={"content": COMMENT_CONTENT},
|
||||
headers=user_headers,
|
||||
)
|
||||
comment_id = comment_resp.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/comments/{comment_id}/like",
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
190
tests/e2e/test_comments.py
Normal file
190
tests/e2e/test_comments.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""End-to-end tests for blog post comments.
|
||||
|
||||
Tests comment creation, nested replies with @username prefix,
|
||||
and comment visibility for authenticated users.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from pytfm.generators import PostDataGenerator
|
||||
|
||||
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
|
||||
|
||||
|
||||
def _unique_title(base: str) -> str:
|
||||
"""Append a short UUID to a title to avoid slug collisions."""
|
||||
return f"{base} {uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_create_and_reply_comment(
|
||||
user_page: Page,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Test TC-E2E-110: Create a top-level comment, reply, and nested reply.
|
||||
|
||||
Steps:
|
||||
1. User creates and publishes a post.
|
||||
2. User navigates to the post detail page.
|
||||
3. User clicks "Write a Comment" button.
|
||||
4. User types a top-level comment and submits it.
|
||||
5. Page reloads, top-level comment is visible.
|
||||
6. User clicks "Reply" on the top-level comment.
|
||||
7. User types a reply and submits it.
|
||||
8. Page reloads, reply is visible under the parent comment.
|
||||
9. User clicks "Reply" on the reply (nested reply).
|
||||
10. User types a nested reply and submits it.
|
||||
11. Page reloads, nested reply is visible under the reply.
|
||||
|
||||
Args:
|
||||
user_page: Playwright page authenticated as regular user.
|
||||
base_url: Application base URL.
|
||||
"""
|
||||
generator = PostDataGenerator()
|
||||
post_data = generator.generate_post()
|
||||
title = _unique_title(str(post_data["title"]))
|
||||
content = str(post_data["content"])
|
||||
tags = ", ".join(post_data["tags"])
|
||||
|
||||
home = HomePage(user_page, base_url)
|
||||
home.open()
|
||||
home.create_post()
|
||||
|
||||
form = PostFormPage(user_page, base_url)
|
||||
form.fill_form(title, content, tags)
|
||||
with user_page.expect_navigation(wait_until="networkidle"):
|
||||
form.publish()
|
||||
current_url = user_page.url
|
||||
assert "new" not in current_url, f"Still on form page: {current_url}"
|
||||
slug = current_url.rstrip("/").split("/")[-1]
|
||||
|
||||
user_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
detail = PostDetailPage(user_page, base_url, slug)
|
||||
assert detail.get_title() == title
|
||||
|
||||
# Click "Write a Comment" button to show the form
|
||||
user_page.locator('[data-testid="btn-show-comment-form"]').click()
|
||||
user_page.wait_for_selector('[data-testid="form-create-comment"]', state="visible")
|
||||
|
||||
# Write a top-level comment
|
||||
comment_text = "This is a top-level comment with enough length for testing."
|
||||
user_page.locator('[data-testid="input-comment-content"]').fill(comment_text)
|
||||
|
||||
# Submit the comment
|
||||
user_page.locator('[data-testid="submit-comment"]').click()
|
||||
|
||||
# Page should reload after successful comment creation
|
||||
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
|
||||
|
||||
# Verify the top-level comment appears
|
||||
comment_locator = user_page.locator('[data-testid^="comment-content-"]', has_text=comment_text)
|
||||
expect(comment_locator.first).to_be_visible(timeout=10000)
|
||||
|
||||
# Get the comment ID for the reply button
|
||||
top_comment = user_page.locator('[data-testid^="comment-"][data-comment-id]').first
|
||||
comment_id = top_comment.get_attribute("data-comment-id")
|
||||
|
||||
# Click Reply on the top-level comment
|
||||
reply_btn = user_page.locator(f'[data-testid="btn-comment-reply-{comment_id}"]')
|
||||
reply_btn.click()
|
||||
|
||||
# The comment form should appear with reply info
|
||||
user_page.wait_for_selector('[data-testid="comment-form-help"]', state="visible")
|
||||
|
||||
# Write a reply
|
||||
reply_text = "This is a reply to the comment."
|
||||
user_page.locator('[data-testid="input-comment-content"]').fill(reply_text)
|
||||
|
||||
# Submit the reply
|
||||
user_page.locator('[data-testid="submit-comment"]').click()
|
||||
|
||||
# Page should reload
|
||||
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
|
||||
|
||||
# Verify the reply appears in the comment-replies section under the parent
|
||||
replies_section = user_page.locator(f'[data-testid="comment-replies-{comment_id}"]')
|
||||
expect(replies_section).to_be_visible(timeout=10000)
|
||||
|
||||
# Verify reply text is visible within the replies section
|
||||
reply_in_section = replies_section.locator(
|
||||
'[data-testid^="comment-content-"]', has_text=reply_text
|
||||
)
|
||||
expect(reply_in_section).to_be_visible(timeout=5000)
|
||||
|
||||
# Get the reply's comment ID for the nested reply
|
||||
reply_element = replies_section.locator('[data-testid^="comment-"][data-comment-id]').first
|
||||
reply_id = reply_element.get_attribute("data-comment-id")
|
||||
|
||||
# Click Reply on the reply (nested reply)
|
||||
user_page.locator(f'[data-testid="btn-comment-reply-{reply_id}"]').click()
|
||||
user_page.wait_for_selector('[data-testid="comment-form-help"]', state="visible")
|
||||
|
||||
# Write a nested reply
|
||||
nested_text = "This is a reply to the reply."
|
||||
user_page.locator('[data-testid="input-comment-content"]').fill(nested_text)
|
||||
|
||||
# Submit the nested reply
|
||||
user_page.locator('[data-testid="submit-comment"]').click()
|
||||
|
||||
# Page should reload
|
||||
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
|
||||
|
||||
# Verify the nested reply appears in the comment-replies section under the reply
|
||||
nested_replies = user_page.locator(f'[data-testid="comment-replies-{reply_id}"]')
|
||||
expect(nested_replies).to_be_visible(timeout=10000)
|
||||
|
||||
# Verify nested reply text is visible
|
||||
nested_in_section = nested_replies.locator(
|
||||
'[data-testid^="comment-content-"]', has_text=nested_text
|
||||
)
|
||||
expect(nested_in_section).to_be_visible(timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_guest_cannot_comment(
|
||||
guest_page: Page,
|
||||
user_page: Page,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Test TC-E2E-112: Guest cannot see the comment form.
|
||||
|
||||
Steps:
|
||||
1. User creates and publishes a post.
|
||||
2. Guest opens the post detail page.
|
||||
3. Verify guest cannot see the "Write a Comment" button.
|
||||
|
||||
Args:
|
||||
guest_page: Unauthenticated Playwright page.
|
||||
user_page: Playwright page authenticated as regular user.
|
||||
base_url: Application base URL.
|
||||
"""
|
||||
generator = PostDataGenerator()
|
||||
post_data = generator.generate_post()
|
||||
title = _unique_title(str(post_data["title"]))
|
||||
content = str(post_data["content"])
|
||||
tags = ", ".join(post_data["tags"])
|
||||
|
||||
home = HomePage(user_page, base_url)
|
||||
home.open()
|
||||
home.create_post()
|
||||
|
||||
form = PostFormPage(user_page, base_url)
|
||||
form.fill_form(title, content, tags)
|
||||
with user_page.expect_navigation(wait_until="networkidle"):
|
||||
form.publish()
|
||||
current_url = user_page.url
|
||||
assert "new" not in current_url, f"Still on form page: {current_url}"
|
||||
slug = current_url.rstrip("/").split("/")[-1]
|
||||
|
||||
# Guest opens the published post
|
||||
guest_detail = PostDetailPage(guest_page, base_url, slug)
|
||||
guest_detail.open()
|
||||
guest_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
|
||||
# Verify guest cannot see comment form or button
|
||||
comment_btn = guest_page.locator('[data-testid="btn-show-comment-form"]')
|
||||
expect(comment_btn).to_have_count(0)
|
||||
128
tests/unit/application/test_create_comment.py
Normal file
128
tests/unit/application/test_create_comment.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for CreateCommentUseCase.
|
||||
|
||||
This module tests comment creation use case covering top-level comments,
|
||||
replies to existing comments, and post-not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.create_comment import CreateCommentUseCase
|
||||
from app.domain.entities import Post
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_post() -> Post:
|
||||
"""Create a test post for comment tests."""
|
||||
return Post.create(
|
||||
title_str="Commentable Post",
|
||||
content_str="This post will receive comments. Enough length here.",
|
||||
author_id="user-123",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
class TestCreateCommentUseCase:
|
||||
"""Tests for CreateCommentUseCase.
|
||||
|
||||
Covers TC-UNIT-832 through TC-UNIT-834.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_on_post(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test creating a top-level comment on a post.
|
||||
|
||||
TC-UNIT-832: Positive — create top-level comment.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(
|
||||
post_id=test_post.id,
|
||||
author_id="user-456",
|
||||
content="Great post! Thanks for sharing.",
|
||||
)
|
||||
|
||||
assert result.post_id == test_post.id
|
||||
assert result.author_id == "user-456"
|
||||
assert result.content == "Great post! Thanks for sharing."
|
||||
assert result.parent_id is None
|
||||
assert result.like_count == 0
|
||||
mock_comment_repository.add.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_reply(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test creating a reply to an existing comment.
|
||||
|
||||
TC-UNIT-833: Positive — reply to comment with parent_id.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
parent_id = uuid4()
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(
|
||||
post_id=test_post.id,
|
||||
author_id="user-456",
|
||||
content="This is a reply.",
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
assert result.parent_id == parent_id
|
||||
assert result.post_id == test_post.id
|
||||
mock_comment_repository.add.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_post_not_found(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test creating a comment on a non-existent post.
|
||||
|
||||
TC-UNIT-834: Negative — post not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(
|
||||
post_id=uuid4(),
|
||||
author_id="user-456",
|
||||
content="Comment on missing post.",
|
||||
)
|
||||
|
||||
mock_comment_repository.add.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
81
tests/unit/application/test_delete_comment.py
Normal file
81
tests/unit/application/test_delete_comment.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Tests for DeleteCommentUseCase.
|
||||
|
||||
This module tests the comment deletion use case covering own comment
|
||||
deletion, admin deletion, and not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.delete_comment import DeleteCommentUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
class TestDeleteCommentUseCase:
|
||||
"""Tests for DeleteCommentUseCase.
|
||||
|
||||
Covers TC-UNIT-836 and TC-UNIT-837.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_own_comment(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test deleting own comment.
|
||||
|
||||
TC-UNIT-836: Positive — user deletes own comment.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
author_id = "user-123"
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str="Comment to delete.",
|
||||
)
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.delete = AsyncMock()
|
||||
|
||||
use_case = DeleteCommentUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
await use_case.execute(
|
||||
comment_id=comment.id,
|
||||
user_id=author_id,
|
||||
)
|
||||
|
||||
mock_comment_repository.delete.assert_called_once_with(comment.id)
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_comment_not_found(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test deleting a non-existent comment.
|
||||
|
||||
TC-UNIT-837: Negative — comment not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = DeleteCommentUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(
|
||||
comment_id=uuid4(),
|
||||
user_id="user-123",
|
||||
)
|
||||
|
||||
mock_comment_repository.delete.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
59
tests/unit/application/test_list_comments.py
Normal file
59
tests/unit/application/test_list_comments.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Tests for ListCommentsUseCase.
|
||||
|
||||
This module tests the comment listing use case covering retrieval
|
||||
of comments by post ID.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.list_comments import ListCommentsUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
|
||||
|
||||
class TestListCommentsUseCase:
|
||||
"""Tests for ListCommentsUseCase.
|
||||
|
||||
Covers TC-UNIT-835.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_comments_by_post(
|
||||
self,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test listing comments for a post.
|
||||
|
||||
TC-UNIT-835: Positive — returns comments for given post_id.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
author_id = "user-123"
|
||||
|
||||
comments = [
|
||||
Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str="First comment.",
|
||||
),
|
||||
Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-456",
|
||||
content_str="Second comment.",
|
||||
),
|
||||
]
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_post = AsyncMock(return_value=comments)
|
||||
|
||||
use_case = ListCommentsUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
)
|
||||
|
||||
result = await use_case.execute(post_id=post_id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].post_id == post_id
|
||||
assert result[0].author_id == author_id
|
||||
assert result[1].author_id == "user-456"
|
||||
mock_comment_repository.get_by_post.assert_called_once_with(post_id)
|
||||
119
tests/unit/application/test_toggle_comment_like.py
Normal file
119
tests/unit/application/test_toggle_comment_like.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for ToggleCommentLikeUseCase.
|
||||
|
||||
This module tests the comment like/unlike toggle use case covering
|
||||
first-time like, unlike, and comment-not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
class TestToggleCommentLikeUseCase:
|
||||
"""Tests for ToggleCommentLikeUseCase.
|
||||
|
||||
Covers TC-UNIT-838 through TC-UNIT-840.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_comment_first_time(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test liking a comment for the first time.
|
||||
|
||||
TC-UNIT-838: Positive — like first time.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-123",
|
||||
content_str="Nice post!",
|
||||
)
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.get_like = AsyncMock(return_value=None)
|
||||
mock_comment_repository.add_like = AsyncMock()
|
||||
mock_comment_repository.remove_like = AsyncMock()
|
||||
mock_comment_repository.update = AsyncMock()
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(comment.id, "user-456")
|
||||
|
||||
assert result.like_count == 1
|
||||
mock_comment_repository.add_like.assert_called_once()
|
||||
mock_comment_repository.remove_like.assert_not_called()
|
||||
mock_comment_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlike_comment_already_liked(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test unliking a comment that is already liked.
|
||||
|
||||
TC-UNIT-839: Positive — unlike (already liked).
|
||||
"""
|
||||
post_id = uuid4()
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-123",
|
||||
content_str="Nice post!",
|
||||
)
|
||||
existing_like = CommentLike(comment_id=comment.id, liked_by="user-456")
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.get_like = AsyncMock(return_value=existing_like)
|
||||
mock_comment_repository.add_like = AsyncMock()
|
||||
mock_comment_repository.remove_like = AsyncMock()
|
||||
mock_comment_repository.update = AsyncMock()
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(comment.id, "user-456")
|
||||
|
||||
assert result.like_count == 0
|
||||
mock_comment_repository.remove_like.assert_called_once()
|
||||
mock_comment_repository.add_like.assert_not_called()
|
||||
mock_comment_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_comment_not_found(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test liking a non-existent comment.
|
||||
|
||||
TC-UNIT-840: Negative — comment not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(uuid4(), "user-456")
|
||||
|
||||
mock_comment_repository.add_like.assert_not_called()
|
||||
mock_comment_repository.remove_like.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
98
tests/unit/domain/test_comment_entity.py
Normal file
98
tests/unit/domain/test_comment_entity.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for Comment domain entity.
|
||||
|
||||
This module tests the Comment entity creation, parent_id support,
|
||||
and BaseEntity integration.
|
||||
"""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
|
||||
|
||||
class TestCommentEntity:
|
||||
"""Tests for the Comment domain entity.
|
||||
|
||||
Covers TC-UNIT-829 and TC-UNIT-830.
|
||||
"""
|
||||
|
||||
def test_comment_creation(self) -> None:
|
||||
"""Test creating a top-level Comment with valid attributes.
|
||||
|
||||
TC-UNIT-829: Positive — create Comment instance.
|
||||
|
||||
Expected:
|
||||
- post_id matches input
|
||||
- author_id matches input
|
||||
- content is CommentContent with correct value
|
||||
- id is a valid UUID
|
||||
- parent_id is None
|
||||
- like_count is 0
|
||||
- created_at is set
|
||||
"""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
author_id = "user-123"
|
||||
content_text = "This is a comment with **Markdown** support."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
)
|
||||
|
||||
assert comment.post_id == post_id
|
||||
assert comment.author_id == author_id
|
||||
assert isinstance(comment.content, CommentContent)
|
||||
assert comment.content.value == content_text
|
||||
assert isinstance(comment.id, UUID)
|
||||
assert comment.parent_id is None
|
||||
assert comment.like_count == 0
|
||||
assert comment.created_at is not None
|
||||
|
||||
def test_comment_with_parent(self) -> None:
|
||||
"""Test creating a reply Comment with parent_id.
|
||||
|
||||
TC-UNIT-830: Positive — create Comment with parent_id.
|
||||
|
||||
Expected:
|
||||
- parent_id matches the provided parent comment ID.
|
||||
- All other attributes set correctly.
|
||||
"""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
parent_id = uuid4()
|
||||
author_id = "user-456"
|
||||
content_text = "This is a reply to another comment."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
assert comment.parent_id == parent_id
|
||||
assert comment.post_id == post_id
|
||||
assert comment.author_id == author_id
|
||||
assert comment.content.value == content_text
|
||||
assert comment.like_count == 0
|
||||
|
||||
def test_comment_to_dict(self) -> None:
|
||||
"""Test Comment to_dict serialization."""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
author_id = "user-123"
|
||||
content_text = "Comment with serialization test."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
)
|
||||
data = comment.to_dict()
|
||||
|
||||
assert data["post_id"] == str(post_id)
|
||||
assert data["author_id"] == author_id
|
||||
assert data["content"] == content_text
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert data["parent_id"] is None
|
||||
assert data["like_count"] == 0
|
||||
50
tests/unit/domain/test_comment_like_entity.py
Normal file
50
tests/unit/domain/test_comment_like_entity.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for CommentLike domain entity.
|
||||
|
||||
This module tests the CommentLike entity creation, attributes,
|
||||
and BaseEntity integration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
|
||||
|
||||
class TestCommentLikeEntity:
|
||||
"""Tests for the CommentLike domain entity.
|
||||
|
||||
Covers TC-UNIT-831.
|
||||
"""
|
||||
|
||||
def test_comment_like_creation(self) -> None:
|
||||
"""Test creating a CommentLike with valid attributes.
|
||||
|
||||
TC-UNIT-831: Positive — create CommentLike instance.
|
||||
|
||||
Expected:
|
||||
- comment_id matches input
|
||||
- liked_by matches input
|
||||
- id is a valid UUID
|
||||
- created_at is set
|
||||
"""
|
||||
comment_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "user-123"
|
||||
|
||||
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
|
||||
assert like.comment_id == comment_id
|
||||
assert like.liked_by == liked_by
|
||||
assert isinstance(like.id, UUID)
|
||||
assert like.created_at is not None
|
||||
|
||||
def test_comment_like_to_dict(self) -> None:
|
||||
"""Test CommentLike to_dict serialization."""
|
||||
comment_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "device-abc-123"
|
||||
|
||||
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
data = like.to_dict()
|
||||
|
||||
assert data["comment_id"] == str(comment_id)
|
||||
assert data["liked_by"] == liked_by
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
Reference in New Issue
Block a user