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.
This commit is contained in:
2026-05-11 15:34:20 +03:00
parent 63da25174e
commit 7ff3fa0992
40 changed files with 3161 additions and 44 deletions

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