Compare commits

...

8 Commits

Author SHA1 Message Date
99acd9d287 Merge pull request 'refactor: update e2e page objects to use SmartLocator .loc() API' (#20) from feature/e2e-smartlocator-update into dev
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #20
2026-05-15 18:10:07 +00:00
96ecad0c6f refactor: update e2e page objects to use SmartLocator .loc() API
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Ultraworked with Sisyphus(https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-15 20:28:55 +03:00
ca84bd7fac Pr commenting (#19)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-13 19:21:43 +00:00
9124aa17d5 feat: add pr-comment step to post CI results on pull requests
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Adds a Woodpecker pipeline step that posts a formatted comment with test results and coverage to Gitea PRs after CI completes.

Comment includes: commit SHA (linked), source/target branches, pipeline link, and a status table for lint, type check, unit tests, integration tests, e2e tests, and coverage percentage.
2026-05-13 21:22:17 +03:00
0e46a5f41b pytfm as external deps (#18)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-11 14:13:40 +00:00
7bf9cce337 fix: replace pytfm workspace dependency with git source for CI compatibility
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Remove the synthetic workspace hack from CI pipeline (fake pyproject.toml
+ git clone was fragile and complex). pytfm is now a clean git dependency
in [tool.uv.sources], so uv resolves it automatically.

Local development still works via: uv add --editable ../pytfm
2026-05-11 16:48:15 +03:00
8ca36cdb44 Comments (#17)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-11 13:03:52 +00:00
7ff3fa0992 feat: add comments feature with nested replies and recursive rendering
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Implement full comments system: domain entities (Comment, CommentLike),
value objects (CommentContent), use cases (CRUD, like toggle), SQLAlchemy
repository, API v1 endpoints, web UI with comment form and nested replies,
i18n translations (EN/RU/FR/DE), and E2E tests.

Fix nested reply (reply-to-reply) not displaying — the flat reply_comments
dict was only queried for top-level comment IDs, so deeply nested replies
were saved to DB (incrementing comment count) but never rendered. Switch
to a recursive Jinja2 macro that renders any nesting depth.
2026-05-11 15:34:20 +03:00
43 changed files with 3206 additions and 106 deletions

View File

@@ -21,21 +21,6 @@ steps:
UV_PYTHON: "3.13"
commands:
- pip install uv
- cd ..
- |
cat > pyproject.toml << 'EOF'
[project]
name = "pyaqa"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
[tool.uv.workspace]
members = ["blog.pyaqa.ru", "pytfm"]
EOF
- git clone https://git.pyaqa.ru/pi3c/pytfm.git
- cd $CI_WORKSPACE
- rm -rf .venv
- uv sync --group lints --group tests --group types --group dev
- name: lint
@@ -130,3 +115,33 @@ steps:
- uv run --no-sync coverage combine .coverage.unit .coverage.integration
- uv run --no-sync coverage report --fail-under=70 --include=app/*
- uv run --no-sync coverage html
- name: pr-comment
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
GITEA_API_TOKEN:
from_secret: gitea_api_token
depends_on: [coverage, lint, type]
when:
event: [pull_request]
commands:
- pip install uv
- |
SHA7=$(printf '%.7s' "${CI_COMMIT_SHA:-unknown}")
COMMIT_URL="${CI_FORGE_URL}/${CI_REPO_OWNER}/${CI_REPO_NAME}/commit/${CI_COMMIT_SHA}"
SOURCE="${CI_COMMIT_SOURCE:-${CI_COMMIT_SOURCE_BRANCH:-?}}"
TARGET="${CI_COMMIT_TARGET:-${CI_COMMIT_TARGET_BRANCH:-?}}"
PIPELINE_URL="${CI_PIPELINE_URL:-}"
COVER=$(uv run --no-sync coverage report --include='app/*' | tail -1 | awk '{print $NF}')
if [ -z "$GITEA_API_TOKEN" ]; then
echo "pr-comment: GITEA_API_TOKEN not set, skipping"
exit 0
fi
FMT='{"body": "## CI Summary\n\n**Commit:** [`%s`](%s)\n**Branch:** `%s` → `%s`\n**Pipeline:** [View](%s)\n\n### Checks\n\n| Check | Status |\n|-------|--------|\n| Lint (ruff + isort) | ✅ |\n| Type check (mypy) | ✅ |\n| Unit tests | ✅ |\n| Integration tests | ✅ |\n| E2E tests | ✅ |\n| Coverage | **%s** |\n\n---\n*Reported by Woodpecker CI*"}'
BODY=$(printf "$FMT" "$SHA7" "$COMMIT_URL" "$SOURCE" "$TARGET" "$PIPELINE_URL" "$COVER")
curl -s -X POST "${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/issues/${CI_COMMIT_PULL_REQUEST}/comments" -H "Authorization: token $${GITEA_API_TOKEN}" -H "Content-Type: application/json" --data-binary "$BODY"

View File

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

View File

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

View 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

View File

@@ -101,3 +101,4 @@ class PostResponseDTO:
created_at: datetime
updated_at: datetime
like_count: int = 0
comment_count: int = 0

View File

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

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

View 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()

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

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

View File

@@ -4,7 +4,7 @@ This module re-exports all domain layer components including
entities, value objects, repositories, and exceptions.
"""
from app.domain.entities import BaseEntity, Post, 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",

View File

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

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

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

View File

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

View 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.
"""
...

View File

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

View 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")

View File

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

View File

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

View File

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

View 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)

View File

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

View File

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

View 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)

View File

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

View 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

View File

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

View File

@@ -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> &mdash; <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);
});
});
});
});

View File

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

View File

@@ -61,7 +61,7 @@ types = [
blog = "app.main:main"
[tool.uv.sources]
pytfm = { workspace = true }
pytfm = { git = "https://git.pyaqa.ru/pi3c/pytfm.git" }
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pytfm.web import BasePage, SmartLocator
from pytfm.web import BasePage
if TYPE_CHECKING:
from playwright.sync_api import Page
@@ -22,21 +22,9 @@ class HomePage(BasePage):
path = "/web/"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the home page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._create_post_btn = SmartLocator.by_testid("btn-create-post-header")
self._post_list = SmartLocator.by_testid("post-list")
self._empty_state = SmartLocator.by_testid("empty-state")
def create_post(self) -> None:
"""Click the 'Write a Post' button to navigate to the form."""
self._create_post_btn.click(self.page)
self.loc("btn-create-post-header").click()
def has_post_with_title(self, title: str) -> bool:
"""Check if a post with the given title is present in the list.
@@ -79,7 +67,7 @@ class HomePage(BasePage):
Returns:
True if the empty state is visible.
"""
return self._empty_state.is_visible(self.page)
return self.loc("empty-state")._get_locator().is_visible()
def count_posts(self) -> int:
"""Count the number of post cards on the page.
@@ -138,20 +126,6 @@ class PostFormPage(BasePage):
path = "/web/posts/new"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the post form page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._title_input = SmartLocator.by_testid("input-title")
self._content_input = SmartLocator.by_testid("textarea-content")
self._tags_input = SmartLocator.by_testid("input-tags")
self._publish_btn = SmartLocator.by_testid("btn-publish-post")
self._save_draft_btn = SmartLocator.by_testid("btn-save-draft")
def fill_form(self, title: str, content: str, tags: str) -> None:
"""Fill the post creation form.
@@ -160,8 +134,8 @@ class PostFormPage(BasePage):
content: Post content (markdown).
tags: Comma-separated tags string.
"""
self._title_input.fill(self.page, title)
self._tags_input.fill(self.page, tags)
self.loc("input-title").fill(title)
self.loc("input-tags").fill(tags)
self.page.evaluate(
"(content) => {"
@@ -177,11 +151,11 @@ class PostFormPage(BasePage):
def publish(self) -> None:
"""Click the publish button to submit the form."""
self._publish_btn.click(self.page)
self.loc("btn-publish-post").click()
def save_draft(self) -> None:
"""Click the 'Save as Draft' button."""
self._save_draft_btn.click(self.page)
self.loc("btn-save-draft").click()
class PostDetailPage(BasePage):
@@ -203,12 +177,6 @@ class PostDetailPage(BasePage):
"""
super().__init__(page, base_url)
self.slug = slug
self._title = SmartLocator.by_testid("post-detail-title")
self._status = SmartLocator.by_testid("post-detail-status")
self._content = SmartLocator.by_testid("post-detail-content")
self._edit_btn = SmartLocator.by_testid("btn-edit-post")
self._delete_btn = SmartLocator.by_testid("btn-delete-post")
self._like_button = SmartLocator.by_testid("like-button")
@property
def url(self) -> str:
@@ -234,7 +202,7 @@ class PostDetailPage(BasePage):
Returns:
Post title string.
"""
return self._title.get_text(self.page)
return self.loc("post-detail-title").text_content() or ""
def get_status(self) -> str:
"""Get the post status badge text.
@@ -242,7 +210,7 @@ class PostDetailPage(BasePage):
Returns:
Status text ('Published' or 'Draft').
"""
return self._status.get_text(self.page)
return self.loc("post-detail-status").text_content() or ""
def is_published(self) -> bool:
"""Check if the post status is 'Published'.
@@ -254,7 +222,7 @@ class PostDetailPage(BasePage):
def edit(self) -> None:
"""Click the edit button to navigate to the edit form."""
self._edit_btn.click(self.page)
self.loc("btn-edit-post").click()
def can_edit(self) -> bool:
"""Check if the edit button is visible.
@@ -262,7 +230,7 @@ class PostDetailPage(BasePage):
Returns:
True if edit button is present.
"""
return self._edit_btn.is_visible(self.page)
return self.loc("btn-edit-post")._get_locator().is_visible()
def can_delete(self) -> bool:
"""Check if the delete button is visible.
@@ -270,12 +238,12 @@ class PostDetailPage(BasePage):
Returns:
True if delete button is present.
"""
return self._delete_btn.is_visible(self.page)
return self.loc("btn-delete-post")._get_locator().is_visible()
def delete(self) -> None:
"""Click the delete button and accept the confirmation dialog."""
self.page.on("dialog", lambda dialog: dialog.accept())
self._delete_btn.click(self.page)
self.loc("btn-delete-post").click()
def get_like_count(self) -> int:
"""Get the current like count from the detail page.
@@ -288,4 +256,4 @@ class PostDetailPage(BasePage):
def click_like(self) -> None:
"""Click the like/unlike button to toggle the like state."""
self._like_button.click(self.page)
self.loc("like-button").click()

190
tests/e2e/test_comments.py Normal file
View 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)

View 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()

View 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()

View 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)

View 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()

View 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

View 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