feat: add like/unlike toggle on blog posts with per-user tracking
- PostLike domain entity (post_id, liked_by) with BaseEntity integration
- Post entity: add like_count field (default 0) and to_dict serialization
- PostRepository interface: add get_like, add_like, remove_like methods
- TogglePostLikeUseCase: toggle logic (like → unlike, unlike → like)
- PostResponseDTO/PostResponseSchema: add like_count field
- PostLikeORM model with FK to posts and cascade delete
- SQLAlchemyPostRepository: implement like query/add/remove with ORM mapping
- DI provider registration for TogglePostLikeUseCase
- API endpoint POST /api/v1/posts/{id}/like (auth required)
- Unit tests: PostLike entity, Post.like_count, TogglePostLikeUseCase (7 tests)
- API tests: POST /api/v1/posts/{id}/like (4 tests)
- Test model files: FEATURE_LIKES.md, TEST_MODEL.md updated
This commit is contained in:
170
tests/unit/application/test_toggle_like.py
Normal file
170
tests/unit/application/test_toggle_like.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Tests for TogglePostLikeUseCase.
|
||||
|
||||
This module tests the like/unlike toggle use case covering
|
||||
first-time like, unlike, post-not-found, guest access, and
|
||||
identity isolation scenarios.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
|
||||
from app.domain.entities import Post
|
||||
from app.domain.entities.like import PostLike
|
||||
from app.domain.exceptions import NotFoundException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_post() -> Post:
|
||||
"""Create a test post for like tests."""
|
||||
return Post.create(
|
||||
title_str="Likeable Post",
|
||||
content_str="This post will be liked and unliked. Enough length here.",
|
||||
author_id="user-123",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
class TestTogglePostLikeUseCase:
|
||||
"""Tests for TogglePostLikeUseCase.
|
||||
|
||||
Covers TC-UNIT-822 through TC-UNIT-825 and TC-UNIT-828.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_post_first_time(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test toggling like on a post for the first time.
|
||||
|
||||
TC-UNIT-822: Positive — like first time.
|
||||
"""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.get_like = AsyncMock(return_value=None)
|
||||
mock_post_repository.add_like = AsyncMock()
|
||||
mock_post_repository.remove_like = AsyncMock()
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
result = await use_case.execute(test_post.id, "user-123")
|
||||
|
||||
assert result.like_count == 1
|
||||
mock_post_repository.add_like.assert_called_once()
|
||||
mock_post_repository.remove_like.assert_not_called()
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlike_post_already_liked(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test toggling like on a post that is already liked.
|
||||
|
||||
TC-UNIT-823: Positive — unlike (already liked).
|
||||
"""
|
||||
existing_like = PostLike(post_id=test_post.id, liked_by="user-123")
|
||||
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.get_like = AsyncMock(return_value=existing_like)
|
||||
mock_post_repository.add_like = AsyncMock()
|
||||
mock_post_repository.remove_like = AsyncMock()
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
result = await use_case.execute(test_post.id, "user-123")
|
||||
|
||||
assert result.like_count == 0
|
||||
mock_post_repository.remove_like.assert_called_once()
|
||||
mock_post_repository.add_like.assert_not_called()
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_post_not_found(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test toggling like on a non-existent post.
|
||||
|
||||
TC-UNIT-824: Negative — post not found.
|
||||
"""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(uuid4(), "user-123")
|
||||
|
||||
mock_post_repository.add_like.assert_not_called()
|
||||
mock_post_repository.remove_like.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_like_as_guest_with_device_id(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test toggling like as a guest using device_id.
|
||||
|
||||
TC-UNIT-825: Positive — guest via device_id.
|
||||
"""
|
||||
device_id = "device-abc-123"
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.get_like = AsyncMock(return_value=None)
|
||||
mock_post_repository.add_like = AsyncMock()
|
||||
mock_post_repository.remove_like = AsyncMock()
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
result = await use_case.execute(test_post.id, device_id)
|
||||
|
||||
assert result.like_count == 1
|
||||
added_like = mock_post_repository.add_like.call_args[0][0]
|
||||
assert added_like.liked_by == device_id
|
||||
assert added_like.post_id == test_post.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_users_can_both_like(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test that two different users can both like the same post.
|
||||
|
||||
TC-UNIT-828: Positive — identity isolation.
|
||||
Both likes are counted independently.
|
||||
"""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.get_like = AsyncMock(return_value=None)
|
||||
mock_post_repository.add_like = AsyncMock()
|
||||
mock_post_repository.remove_like = AsyncMock()
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
result1 = await use_case.execute(test_post.id, "user-123")
|
||||
assert result1.like_count == 1
|
||||
|
||||
mock_post_repository.add_like.reset_mock()
|
||||
mock_post_repository.update.reset_mock()
|
||||
mock_transaction_manager.commit.reset_mock()
|
||||
|
||||
result2 = await use_case.execute(test_post.id, "user-456")
|
||||
assert result2.like_count == 2
|
||||
|
||||
assert mock_post_repository.add_like.call_count == 1
|
||||
@@ -128,6 +128,19 @@ class TestPost:
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
def test_like_count_defaults_to_zero(self) -> None:
|
||||
"""Test that a new post has like_count defaulting to 0.
|
||||
|
||||
TC-UNIT-827: Positive — like_count defaults to zero on creation.
|
||||
"""
|
||||
post = Post.create(
|
||||
title_str="Test Post",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
assert post.like_count == 0
|
||||
|
||||
def test_base_entity_eq_and_hash(self) -> None:
|
||||
"""Test BaseEntity equality and hash directly."""
|
||||
from app.domain.entities.base import BaseEntity
|
||||
|
||||
50
tests/unit/domain/test_like_entity.py
Normal file
50
tests/unit/domain/test_like_entity.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for PostLike domain entity.
|
||||
|
||||
This module tests the PostLike entity creation, attributes,
|
||||
and BaseEntity integration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.like import PostLike
|
||||
|
||||
|
||||
class TestPostLikeEntity:
|
||||
"""Tests for the PostLike domain entity.
|
||||
|
||||
Covers TC-UNIT-826: PostLike entity valid creation.
|
||||
"""
|
||||
|
||||
def test_post_like_creation(self) -> None:
|
||||
"""Test creating a PostLike with valid attributes.
|
||||
|
||||
TC-UNIT-826: Positive — create PostLike instance.
|
||||
|
||||
Expected:
|
||||
- post_id matches input
|
||||
- liked_by matches input
|
||||
- id is a valid UUID
|
||||
- created_at is set
|
||||
"""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "user-123"
|
||||
|
||||
like = PostLike(post_id=post_id, liked_by=liked_by)
|
||||
|
||||
assert like.post_id == post_id
|
||||
assert like.liked_by == liked_by
|
||||
assert isinstance(like.id, UUID)
|
||||
assert like.created_at is not None
|
||||
|
||||
def test_post_like_to_dict(self) -> None:
|
||||
"""Test PostLike to_dict serialization."""
|
||||
post_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
liked_by = "device-abc-123"
|
||||
|
||||
like = PostLike(post_id=post_id, liked_by=liked_by)
|
||||
data = like.to_dict()
|
||||
|
||||
assert data["post_id"] == str(post_id)
|
||||
assert data["liked_by"] == liked_by
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
Reference in New Issue
Block a user