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()
|
||||
Reference in New Issue
Block a user