feat(tests): increase test coverage from 68% to 78%
Some checks failed
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline failed

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:
2026-05-02 18:40:29 +03:00
parent 41b6698c55
commit ce2c052684
7 changed files with 1450 additions and 0 deletions

View 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 == []

View 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()

View 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()