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

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

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