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:
2026-05-10 18:24:09 +03:00
parent 4497f452a1
commit 3cf6c94da2
21 changed files with 876 additions and 6 deletions

View 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

View File

@@ -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

View 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