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