feat(tests): increase test coverage from 68% to 78%
Add comprehensive integration and API tests: - Integration tests for SQLAlchemyPostRepository (34 tests) - API tests for posts endpoints and error handlers (22 tests) - Unit tests for PublishPostUseCase and ListPostsUseCase - Unit tests for SessionTransactionManager Also register generic exception handler in error_handler.py All 167 tests pass, coverage now meets CI threshold of 70%
This commit is contained in:
225
tests/unit/application/test_list_posts.py
Normal file
225
tests/unit/application/test_list_posts.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Tests for ListPostsUseCase.
|
||||
|
||||
Tests listing posts with various filters.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.dtos import PostResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.application.use_cases.list_posts import ListPostsUseCase
|
||||
from app.domain.entities import Post
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.domain.value_objects import Content, Slug, Title
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_post_repository() -> MagicMock:
|
||||
"""Create mock post repository."""
|
||||
return MagicMock(spec=PostRepository)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_transaction_manager() -> MagicMock:
|
||||
"""Create mock transaction manager."""
|
||||
return MagicMock(spec=TransactionManager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_use_case(
|
||||
mock_post_repository: MagicMock,
|
||||
mock_transaction_manager: MagicMock,
|
||||
) -> ListPostsUseCase:
|
||||
"""Create list use case with mocked dependencies."""
|
||||
return ListPostsUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_posts() -> list[Post]:
|
||||
"""Create sample posts for testing."""
|
||||
return [
|
||||
Post(
|
||||
id=uuid4(),
|
||||
title=Title(f"Post {i}"),
|
||||
content=Content(f"Content for post number {i}"),
|
||||
slug=Slug(f"post-{i}"),
|
||||
author_id="author-123",
|
||||
published=i % 2 == 0,
|
||||
tags=["python"] if i == 0 else [],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
|
||||
class TestAllPosts:
|
||||
"""Test suite for all_posts method."""
|
||||
|
||||
async def test_all_posts(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_posts: list[Post],
|
||||
) -> None:
|
||||
"""Test getting all posts."""
|
||||
mock_post_repository.get_all = AsyncMock(return_value=sample_posts)
|
||||
|
||||
result = await list_use_case.all_posts()
|
||||
|
||||
assert len(result) == 3
|
||||
assert all(isinstance(dto, PostResponseDTO) for dto in result)
|
||||
mock_post_repository.get_all.assert_called_once()
|
||||
|
||||
async def test_all_posts_empty(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test getting all posts when empty."""
|
||||
mock_post_repository.get_all = AsyncMock(return_value=[])
|
||||
|
||||
result = await list_use_case.all_posts()
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPublishedPosts:
|
||||
"""Test suite for published_posts method."""
|
||||
|
||||
async def test_published_posts(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_posts: list[Post],
|
||||
) -> None:
|
||||
"""Test getting published posts."""
|
||||
published = [p for p in sample_posts if p.published]
|
||||
mock_post_repository.get_published = AsyncMock(return_value=published)
|
||||
|
||||
result = await list_use_case.published_posts()
|
||||
|
||||
assert len(result) == 2 # posts 0 and 2 are published
|
||||
assert all(dto.published for dto in result)
|
||||
|
||||
async def test_published_posts_with_limit_offset(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test getting published posts with pagination."""
|
||||
mock_post_repository.get_published = AsyncMock(return_value=[])
|
||||
|
||||
result = await list_use_case.published_posts(limit=5, offset=10)
|
||||
|
||||
mock_post_repository.get_published.assert_called_once_with(limit=5, offset=10)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestByAuthor:
|
||||
"""Test suite for by_author method."""
|
||||
|
||||
async def test_by_author(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_posts: list[Post],
|
||||
) -> None:
|
||||
"""Test getting posts by author."""
|
||||
mock_post_repository.get_by_author = AsyncMock(return_value=sample_posts)
|
||||
|
||||
result = await list_use_case.by_author("author-123")
|
||||
|
||||
assert len(result) == 3
|
||||
mock_post_repository.get_by_author.assert_called_once_with(
|
||||
"author-123", limit=None, offset=None
|
||||
)
|
||||
|
||||
async def test_by_author_with_pagination(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test getting posts by author with pagination."""
|
||||
mock_post_repository.get_by_author = AsyncMock(return_value=[])
|
||||
|
||||
await list_use_case.by_author("author-123", limit=5, offset=0)
|
||||
|
||||
mock_post_repository.get_by_author.assert_called_once_with("author-123", limit=5, offset=0)
|
||||
|
||||
|
||||
class TestByTag:
|
||||
"""Test suite for by_tag method."""
|
||||
|
||||
async def test_by_tag(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_posts: list[Post],
|
||||
) -> None:
|
||||
"""Test getting posts by tag."""
|
||||
tagged_posts = [sample_posts[0]]
|
||||
mock_post_repository.get_by_tag = AsyncMock(return_value=tagged_posts)
|
||||
|
||||
result = await list_use_case.by_tag("python")
|
||||
|
||||
assert len(result) == 1
|
||||
assert "python" in result[0].tags
|
||||
|
||||
async def test_by_tag_empty(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test getting posts by non-existent tag."""
|
||||
mock_post_repository.get_by_tag = AsyncMock(return_value=[])
|
||||
|
||||
result = await list_use_case.by_tag("nonexistent")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Test suite for search method."""
|
||||
|
||||
async def test_search(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_posts: list[Post],
|
||||
) -> None:
|
||||
"""Test searching posts."""
|
||||
mock_post_repository.search = AsyncMock(return_value=sample_posts)
|
||||
|
||||
result = await list_use_case.search("test query")
|
||||
|
||||
assert len(result) == 3
|
||||
mock_post_repository.search.assert_called_once_with("test query", limit=None, offset=None)
|
||||
|
||||
async def test_search_with_pagination(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test searching posts with pagination."""
|
||||
mock_post_repository.search = AsyncMock(return_value=[])
|
||||
|
||||
await list_use_case.search("query", limit=10, offset=5)
|
||||
|
||||
mock_post_repository.search.assert_called_once_with("query", limit=10, offset=5)
|
||||
|
||||
async def test_search_no_results(
|
||||
self,
|
||||
list_use_case: ListPostsUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test searching with no matches."""
|
||||
mock_post_repository.search = AsyncMock(return_value=[])
|
||||
|
||||
result = await list_use_case.search("xyz123")
|
||||
|
||||
assert result == []
|
||||
174
tests/unit/application/test_publish_post.py
Normal file
174
tests/unit/application/test_publish_post.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for PublishPostUseCase.
|
||||
|
||||
Tests publishing and unpublishing posts with authorization.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.dtos import PostResponseDTO
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.application.use_cases.publish_post import PublishPostUseCase
|
||||
from app.domain.entities import Post
|
||||
from app.domain.exceptions import ForbiddenException, NotFoundException
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.domain.value_objects import Content, Slug, Title
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_post_repository() -> MagicMock:
|
||||
"""Create mock post repository."""
|
||||
return MagicMock(spec=PostRepository)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_transaction_manager() -> MagicMock:
|
||||
"""Create mock transaction manager."""
|
||||
tx = MagicMock(spec=TransactionManager)
|
||||
tx.commit = AsyncMock()
|
||||
return tx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def publish_use_case(
|
||||
mock_post_repository: MagicMock,
|
||||
mock_transaction_manager: MagicMock,
|
||||
) -> PublishPostUseCase:
|
||||
"""Create publish use case with mocked dependencies."""
|
||||
return PublishPostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_post() -> Post:
|
||||
"""Create a sample unpublished post."""
|
||||
return Post(
|
||||
id=uuid4(),
|
||||
title=Title("Test Post"),
|
||||
content=Content("Test content"),
|
||||
slug=Slug("test-post"),
|
||||
author_id="author-123",
|
||||
published=False,
|
||||
tags=["test"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def published_post() -> Post:
|
||||
"""Create a sample published post."""
|
||||
post = Post(
|
||||
id=uuid4(),
|
||||
title=Title("Published Post"),
|
||||
content=Content("Published content"),
|
||||
slug=Slug("published-post"),
|
||||
author_id="author-123",
|
||||
published=True,
|
||||
tags=["published"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
return post
|
||||
|
||||
|
||||
class TestPublishPost:
|
||||
"""Test suite for publish method."""
|
||||
|
||||
async def test_publish_success(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
mock_transaction_manager: MagicMock,
|
||||
sample_post: Post,
|
||||
) -> None:
|
||||
"""Test successful post publishing."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=sample_post)
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
result = await publish_use_case.publish(sample_post.id, sample_post.author_id)
|
||||
|
||||
assert isinstance(result, PostResponseDTO)
|
||||
assert result.id == sample_post.id
|
||||
assert result.published is True
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
async def test_publish_not_found(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test publishing non-existent post raises NotFoundException."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(NotFoundException) as exc_info:
|
||||
await publish_use_case.publish(uuid4(), "author-123")
|
||||
|
||||
assert "not found" in str(exc_info.value).lower()
|
||||
|
||||
async def test_publish_forbidden(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
sample_post: Post,
|
||||
) -> None:
|
||||
"""Test publishing other user's post raises ForbiddenException."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=sample_post)
|
||||
|
||||
with pytest.raises(ForbiddenException) as exc_info:
|
||||
await publish_use_case.publish(sample_post.id, "different-author")
|
||||
|
||||
assert "own posts" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestUnpublishPost:
|
||||
"""Test suite for unpublish method."""
|
||||
|
||||
async def test_unpublish_success(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
mock_transaction_manager: MagicMock,
|
||||
published_post: Post,
|
||||
) -> None:
|
||||
"""Test successful post unpublishing."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=published_post)
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
result = await publish_use_case.unpublish(published_post.id, published_post.author_id)
|
||||
|
||||
assert isinstance(result, PostResponseDTO)
|
||||
assert result.id == published_post.id
|
||||
assert result.published is False
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
async def test_unpublish_not_found(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
) -> None:
|
||||
"""Test unpublishing non-existent post raises NotFoundException."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(NotFoundException) as exc_info:
|
||||
await publish_use_case.unpublish(uuid4(), "author-123")
|
||||
|
||||
assert "not found" in str(exc_info.value).lower()
|
||||
|
||||
async def test_unpublish_forbidden(
|
||||
self,
|
||||
publish_use_case: PublishPostUseCase,
|
||||
mock_post_repository: MagicMock,
|
||||
published_post: Post,
|
||||
) -> None:
|
||||
"""Test unpublishing other user's post raises ForbiddenException."""
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=published_post)
|
||||
|
||||
with pytest.raises(ForbiddenException) as exc_info:
|
||||
await publish_use_case.unpublish(published_post.id, "different-author")
|
||||
|
||||
assert "own posts" in str(exc_info.value).lower()
|
||||
46
tests/unit/infrastructure/test_transaction_manager.py
Normal file
46
tests/unit/infrastructure/test_transaction_manager.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for DI transaction manager.
|
||||
|
||||
Tests SessionTransactionManager implementation.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.di.transaction_manager import SessionTransactionManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session() -> MagicMock:
|
||||
"""Create mock async session."""
|
||||
session = MagicMock(spec=AsyncSession)
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transaction_manager(mock_session: MagicMock) -> SessionTransactionManager:
|
||||
"""Create transaction manager with mock session."""
|
||||
return SessionTransactionManager(mock_session)
|
||||
|
||||
|
||||
class TestSessionTransactionManager:
|
||||
"""Test suite for SessionTransactionManager."""
|
||||
|
||||
async def test_commit(
|
||||
self, transaction_manager: SessionTransactionManager, mock_session: MagicMock
|
||||
) -> None:
|
||||
"""Test commit calls session commit."""
|
||||
await transaction_manager.commit()
|
||||
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
async def test_rollback(
|
||||
self, transaction_manager: SessionTransactionManager, mock_session: MagicMock
|
||||
) -> None:
|
||||
"""Test rollback calls session rollback."""
|
||||
await transaction_manager.rollback()
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
Reference in New Issue
Block a user