feat: add comments feature with nested replies and recursive rendering
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
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:
128
tests/unit/application/test_create_comment.py
Normal file
128
tests/unit/application/test_create_comment.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for CreateCommentUseCase.
|
||||
|
||||
This module tests comment creation use case covering top-level comments,
|
||||
replies to existing comments, and post-not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.create_comment import CreateCommentUseCase
|
||||
from app.domain.entities import Post
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_post() -> Post:
|
||||
"""Create a test post for comment tests."""
|
||||
return Post.create(
|
||||
title_str="Commentable Post",
|
||||
content_str="This post will receive comments. Enough length here.",
|
||||
author_id="user-123",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
class TestCreateCommentUseCase:
|
||||
"""Tests for CreateCommentUseCase.
|
||||
|
||||
Covers TC-UNIT-832 through TC-UNIT-834.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_on_post(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test creating a top-level comment on a post.
|
||||
|
||||
TC-UNIT-832: Positive — create top-level comment.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(
|
||||
post_id=test_post.id,
|
||||
author_id="user-456",
|
||||
content="Great post! Thanks for sharing.",
|
||||
)
|
||||
|
||||
assert result.post_id == test_post.id
|
||||
assert result.author_id == "user-456"
|
||||
assert result.content == "Great post! Thanks for sharing."
|
||||
assert result.parent_id is None
|
||||
assert result.like_count == 0
|
||||
mock_comment_repository.add.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_reply(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test creating a reply to an existing comment.
|
||||
|
||||
TC-UNIT-833: Positive — reply to comment with parent_id.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
parent_id = uuid4()
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(
|
||||
post_id=test_post.id,
|
||||
author_id="user-456",
|
||||
content="This is a reply.",
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
assert result.parent_id == parent_id
|
||||
assert result.post_id == test_post.id
|
||||
mock_comment_repository.add.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_comment_post_not_found(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test creating a comment on a non-existent post.
|
||||
|
||||
TC-UNIT-834: Negative — post not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = CreateCommentUseCase(
|
||||
post_repo=mock_post_repository,
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(
|
||||
post_id=uuid4(),
|
||||
author_id="user-456",
|
||||
content="Comment on missing post.",
|
||||
)
|
||||
|
||||
mock_comment_repository.add.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
81
tests/unit/application/test_delete_comment.py
Normal file
81
tests/unit/application/test_delete_comment.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Tests for DeleteCommentUseCase.
|
||||
|
||||
This module tests the comment deletion use case covering own comment
|
||||
deletion, admin deletion, and not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.delete_comment import DeleteCommentUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
class TestDeleteCommentUseCase:
|
||||
"""Tests for DeleteCommentUseCase.
|
||||
|
||||
Covers TC-UNIT-836 and TC-UNIT-837.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_own_comment(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test deleting own comment.
|
||||
|
||||
TC-UNIT-836: Positive — user deletes own comment.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
author_id = "user-123"
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str="Comment to delete.",
|
||||
)
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.delete = AsyncMock()
|
||||
|
||||
use_case = DeleteCommentUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
await use_case.execute(
|
||||
comment_id=comment.id,
|
||||
user_id=author_id,
|
||||
)
|
||||
|
||||
mock_comment_repository.delete.assert_called_once_with(comment.id)
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_comment_not_found(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test deleting a non-existent comment.
|
||||
|
||||
TC-UNIT-837: Negative — comment not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = DeleteCommentUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(
|
||||
comment_id=uuid4(),
|
||||
user_id="user-123",
|
||||
)
|
||||
|
||||
mock_comment_repository.delete.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
59
tests/unit/application/test_list_comments.py
Normal file
59
tests/unit/application/test_list_comments.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Tests for ListCommentsUseCase.
|
||||
|
||||
This module tests the comment listing use case covering retrieval
|
||||
of comments by post ID.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.list_comments import ListCommentsUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
|
||||
|
||||
class TestListCommentsUseCase:
|
||||
"""Tests for ListCommentsUseCase.
|
||||
|
||||
Covers TC-UNIT-835.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_comments_by_post(
|
||||
self,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test listing comments for a post.
|
||||
|
||||
TC-UNIT-835: Positive — returns comments for given post_id.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
author_id = "user-123"
|
||||
|
||||
comments = [
|
||||
Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str="First comment.",
|
||||
),
|
||||
Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-456",
|
||||
content_str="Second comment.",
|
||||
),
|
||||
]
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_post = AsyncMock(return_value=comments)
|
||||
|
||||
use_case = ListCommentsUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
)
|
||||
|
||||
result = await use_case.execute(post_id=post_id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].post_id == post_id
|
||||
assert result[0].author_id == author_id
|
||||
assert result[1].author_id == "user-456"
|
||||
mock_comment_repository.get_by_post.assert_called_once_with(post_id)
|
||||
119
tests/unit/application/test_toggle_comment_like.py
Normal file
119
tests/unit/application/test_toggle_comment_like.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for ToggleCommentLikeUseCase.
|
||||
|
||||
This module tests the comment like/unlike toggle use case covering
|
||||
first-time like, unlike, and comment-not-found scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
class TestToggleCommentLikeUseCase:
|
||||
"""Tests for ToggleCommentLikeUseCase.
|
||||
|
||||
Covers TC-UNIT-838 through TC-UNIT-840.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_comment_first_time(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test liking a comment for the first time.
|
||||
|
||||
TC-UNIT-838: Positive — like first time.
|
||||
"""
|
||||
post_id = uuid4()
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-123",
|
||||
content_str="Nice post!",
|
||||
)
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.get_like = AsyncMock(return_value=None)
|
||||
mock_comment_repository.add_like = AsyncMock()
|
||||
mock_comment_repository.remove_like = AsyncMock()
|
||||
mock_comment_repository.update = AsyncMock()
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(comment.id, "user-456")
|
||||
|
||||
assert result.like_count == 1
|
||||
mock_comment_repository.add_like.assert_called_once()
|
||||
mock_comment_repository.remove_like.assert_not_called()
|
||||
mock_comment_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlike_comment_already_liked(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test unliking a comment that is already liked.
|
||||
|
||||
TC-UNIT-839: Positive — unlike (already liked).
|
||||
"""
|
||||
post_id = uuid4()
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id="user-123",
|
||||
content_str="Nice post!",
|
||||
)
|
||||
existing_like = CommentLike(comment_id=comment.id, liked_by="user-456")
|
||||
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
|
||||
mock_comment_repository.get_like = AsyncMock(return_value=existing_like)
|
||||
mock_comment_repository.add_like = AsyncMock()
|
||||
mock_comment_repository.remove_like = AsyncMock()
|
||||
mock_comment_repository.update = AsyncMock()
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
result = await use_case.execute(comment.id, "user-456")
|
||||
|
||||
assert result.like_count == 0
|
||||
mock_comment_repository.remove_like.assert_called_once()
|
||||
mock_comment_repository.add_like.assert_not_called()
|
||||
mock_comment_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_comment_not_found(
|
||||
self,
|
||||
mock_transaction_manager: AsyncMock,
|
||||
) -> None:
|
||||
"""Test liking a non-existent comment.
|
||||
|
||||
TC-UNIT-840: Negative — comment not found.
|
||||
"""
|
||||
mock_comment_repository = AsyncMock()
|
||||
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = ToggleCommentLikeUseCase(
|
||||
comment_repo=mock_comment_repository,
|
||||
tx_manager=mock_transaction_manager,
|
||||
)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(uuid4(), "user-456")
|
||||
|
||||
mock_comment_repository.add_like.assert_not_called()
|
||||
mock_comment_repository.remove_like.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
98
tests/unit/domain/test_comment_entity.py
Normal file
98
tests/unit/domain/test_comment_entity.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for Comment domain entity.
|
||||
|
||||
This module tests the Comment entity creation, parent_id support,
|
||||
and BaseEntity integration.
|
||||
"""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.domain.entities.comment import Comment
|
||||
from app.domain.value_objects.comment_content import CommentContent
|
||||
|
||||
|
||||
class TestCommentEntity:
|
||||
"""Tests for the Comment domain entity.
|
||||
|
||||
Covers TC-UNIT-829 and TC-UNIT-830.
|
||||
"""
|
||||
|
||||
def test_comment_creation(self) -> None:
|
||||
"""Test creating a top-level Comment with valid attributes.
|
||||
|
||||
TC-UNIT-829: Positive — create Comment instance.
|
||||
|
||||
Expected:
|
||||
- post_id matches input
|
||||
- author_id matches input
|
||||
- content is CommentContent with correct value
|
||||
- id is a valid UUID
|
||||
- parent_id is None
|
||||
- like_count is 0
|
||||
- created_at is set
|
||||
"""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
author_id = "user-123"
|
||||
content_text = "This is a comment with **Markdown** support."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
)
|
||||
|
||||
assert comment.post_id == post_id
|
||||
assert comment.author_id == author_id
|
||||
assert isinstance(comment.content, CommentContent)
|
||||
assert comment.content.value == content_text
|
||||
assert isinstance(comment.id, UUID)
|
||||
assert comment.parent_id is None
|
||||
assert comment.like_count == 0
|
||||
assert comment.created_at is not None
|
||||
|
||||
def test_comment_with_parent(self) -> None:
|
||||
"""Test creating a reply Comment with parent_id.
|
||||
|
||||
TC-UNIT-830: Positive — create Comment with parent_id.
|
||||
|
||||
Expected:
|
||||
- parent_id matches the provided parent comment ID.
|
||||
- All other attributes set correctly.
|
||||
"""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
parent_id = uuid4()
|
||||
author_id = "user-456"
|
||||
content_text = "This is a reply to another comment."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
assert comment.parent_id == parent_id
|
||||
assert comment.post_id == post_id
|
||||
assert comment.author_id == author_id
|
||||
assert comment.content.value == content_text
|
||||
assert comment.like_count == 0
|
||||
|
||||
def test_comment_to_dict(self) -> None:
|
||||
"""Test Comment to_dict serialization."""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
author_id = "user-123"
|
||||
content_text = "Comment with serialization test."
|
||||
|
||||
comment = Comment.create(
|
||||
post_id=post_id,
|
||||
author_id=author_id,
|
||||
content_str=content_text,
|
||||
)
|
||||
data = comment.to_dict()
|
||||
|
||||
assert data["post_id"] == str(post_id)
|
||||
assert data["author_id"] == author_id
|
||||
assert data["content"] == content_text
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert data["parent_id"] is None
|
||||
assert data["like_count"] == 0
|
||||
50
tests/unit/domain/test_comment_like_entity.py
Normal file
50
tests/unit/domain/test_comment_like_entity.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for CommentLike domain entity.
|
||||
|
||||
This module tests the CommentLike entity creation, attributes,
|
||||
and BaseEntity integration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.comment_like import CommentLike
|
||||
|
||||
|
||||
class TestCommentLikeEntity:
|
||||
"""Tests for the CommentLike domain entity.
|
||||
|
||||
Covers TC-UNIT-831.
|
||||
"""
|
||||
|
||||
def test_comment_like_creation(self) -> None:
|
||||
"""Test creating a CommentLike with valid attributes.
|
||||
|
||||
TC-UNIT-831: Positive — create CommentLike instance.
|
||||
|
||||
Expected:
|
||||
- comment_id matches input
|
||||
- liked_by matches input
|
||||
- id is a valid UUID
|
||||
- created_at is set
|
||||
"""
|
||||
comment_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "user-123"
|
||||
|
||||
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
|
||||
assert like.comment_id == comment_id
|
||||
assert like.liked_by == liked_by
|
||||
assert isinstance(like.id, UUID)
|
||||
assert like.created_at is not None
|
||||
|
||||
def test_comment_like_to_dict(self) -> None:
|
||||
"""Test CommentLike to_dict serialization."""
|
||||
comment_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "device-abc-123"
|
||||
|
||||
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
|
||||
data = like.to_dict()
|
||||
|
||||
assert data["comment_id"] == str(comment_id)
|
||||
assert data["liked_by"] == liked_by
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
Reference in New Issue
Block a user