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:
285
tests/FEATURE_COMMENTS.md
Normal file
285
tests/FEATURE_COMMENTS.md
Normal 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
|
||||
@@ -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
239
tests/api/test_comments.py
Normal 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
190
tests/e2e/test_comments.py
Normal 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)
|
||||
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