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,207 @@
"""Tests for error handler middleware.
Tests exception handling and error responses.
"""
from unittest.mock import patch
from httpx import ASGITransport, AsyncClient
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
ValidationException,
)
from app.main import app_factory
class TestDomainExceptionHandlers:
"""Test suite for domain exception handlers."""
async def test_validation_exception(self) -> None:
"""Test ValidationException returns 400."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=ValidationException("Invalid input"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 400
data = response.json()
assert data["error"] == "ValidationException"
assert data["message"] == "Invalid input"
assert "timestamp" in data
assert "path" in data
async def test_forbidden_exception(self) -> None:
"""Test ForbiddenException returns 403."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=ForbiddenException("Access denied"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 403
data = response.json()
assert data["error"] == "ForbiddenException"
assert data["message"] == "Access denied"
async def test_not_found_exception(self) -> None:
"""Test NotFoundException returns 404."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=NotFoundException("Post not found"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 404
data = response.json()
assert data["error"] == "NotFoundException"
assert data["message"] == "Post not found"
async def test_already_exists_exception(self) -> None:
"""Test AlreadyExistsException returns 409."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=AlreadyExistsException("Post already exists"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 409
data = response.json()
assert data["error"] == "AlreadyExistsException"
assert data["message"] == "Post already exists"
async def test_generic_domain_exception(self) -> None:
"""Test generic DomainException returns 500."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=DomainException("Generic error"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 500
data = response.json()
assert data["error"] == "DomainException"
assert data["message"] == "Generic error"
class TestHTTPExceptionHandler:
"""Test suite for HTTP exception handling."""
async def test_http_exception_structure(self) -> None:
"""Test HTTP exception response structure."""
# Test that exception handler is registered and produces correct format
import json
from dataclasses import dataclass, field
from starlette.exceptions import HTTPException
from app.infrastructure.middleware.error_handler import http_exception_handler
# Create mock request
@dataclass
class MockURL:
path: str = "/test"
@dataclass
class MockRequest:
url: MockURL = field(default_factory=MockURL)
exc = HTTPException(status_code=404, detail="Not found")
response = await http_exception_handler(MockRequest(), exc) # type: ignore[arg-type]
assert response.status_code == 404
body_bytes: bytes = response.body # type: ignore[assignment]
data: dict[str, object] = json.loads(body_bytes.decode("utf-8"))
assert data["error"] == "HTTPException"
assert "message" in data
class TestGenericExceptionHandler:
"""Test suite for generic exception handling."""
async def test_generic_exception_handler_function(self) -> None:
"""Test generic exception handler function directly."""
import json
from dataclasses import dataclass, field
from app.infrastructure.middleware.error_handler import (
generic_exception_handler,
)
# Create mock request
@dataclass
class MockURL:
path: str = "/test"
@dataclass
class MockRequest:
url: MockURL = field(default_factory=MockURL)
exc = RuntimeError("Internal error")
response = await generic_exception_handler(MockRequest(), exc) # type: ignore[arg-type]
assert response.status_code == 500
body_bytes: bytes = response.body # type: ignore[assignment]
data: dict[str, object] = json.loads(body_bytes.decode("utf-8"))
assert data["error"] == "InternalServerError"
assert data["message"] == "An unexpected error occurred"
assert "timestamp" in data
assert "path" in data
class TestGetStatusCode:
"""Test suite for get_status_code function."""
def test_validation_exception_status(self) -> None:
"""Test ValidationException maps to 400."""
from app.infrastructure.middleware.error_handler import get_status_code
exc = ValidationException("Invalid")
assert get_status_code(exc) == 400
def test_forbidden_exception_status(self) -> None:
"""Test ForbiddenException maps to 403."""
from app.infrastructure.middleware.error_handler import get_status_code
exc = ForbiddenException("Forbidden")
assert get_status_code(exc) == 403
def test_not_found_exception_status(self) -> None:
"""Test NotFoundException maps to 404."""
from app.infrastructure.middleware.error_handler import get_status_code
exc = NotFoundException("Not found")
assert get_status_code(exc) == 404
def test_already_exists_exception_status(self) -> None:
"""Test AlreadyExistsException maps to 409."""
from app.infrastructure.middleware.error_handler import get_status_code
exc = AlreadyExistsException("Already exists")
assert get_status_code(exc) == 409
def test_generic_exception_status(self) -> None:
"""Test generic DomainException maps to 500."""
from app.infrastructure.middleware.error_handler import get_status_code
exc = DomainException("Generic")
assert get_status_code(exc) == 500

318
tests/api/test_posts.py Normal file
View File

@@ -0,0 +1,318 @@
"""API tests for posts endpoints.
Tests REST API endpoints - focusing on endpoints that don't require
complex Dishka dependency mocking.
"""
from datetime import datetime
from unittest.mock import patch
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.dtos import PostResponseDTO
from app.domain.exceptions import NotFoundException
from app.main import app_factory
@pytest.fixture
def sample_post_dto() -> PostResponseDTO:
"""Create a sample post DTO for testing."""
return PostResponseDTO(
id=uuid4(),
title="Test Post",
content="This is test content for the blog post",
slug="test-post",
author_id="test-user-id",
published=True,
tags=["python", "testing"],
created_at=datetime.now(),
updated_at=datetime.now(),
)
class TestListPublishedPosts:
"""Test suite for GET /api/v1/posts/published endpoint."""
async def test_list_published_posts(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test listing published posts without authentication."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.published_posts",
return_value=[sample_post_dto],
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/published")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert data["total"] == 1
class TestSearchPosts:
"""Test suite for GET /api/v1/posts/search endpoint."""
async def test_search_posts(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test searching posts by query."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.search",
return_value=[sample_post_dto],
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/search?query=test")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert data["total"] == 1
async def test_search_posts_empty_query(self) -> None:
"""Test search with empty query returns empty results."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.search",
return_value=[],
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/search?query=")
# Empty query returns 200 with empty results (not 422)
# as query param accepts empty strings
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
class TestGetPostsByTag:
"""Test suite for GET /api/v1/posts/by-tag/{tag} endpoint."""
async def test_get_posts_by_tag(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test getting posts by tag."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.by_tag",
return_value=[sample_post_dto],
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/by-tag/python")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert data["total"] == 1
class TestGetPostsByAuthor:
"""Test suite for GET /api/v1/posts/by-author/{author_id} endpoint."""
async def test_get_posts_by_author(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test getting posts by author."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.by_author",
return_value=[sample_post_dto],
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/by-author/test-user-id")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert data["total"] == 1
class TestGetPostById:
"""Test suite for GET /api/v1/posts/{post_id} endpoint."""
async def test_get_post_by_id_success(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test getting a post by ID."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
return_value=sample_post_dto,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(f"/api/v1/posts/{sample_post_dto.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(sample_post_dto.id)
assert data["title"] == sample_post_dto.title
async def test_get_post_by_id_not_found(self) -> None:
"""Test getting a non-existing post returns 404."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_id",
side_effect=NotFoundException("Post not found"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(f"/api/v1/posts/{uuid4()}")
assert response.status_code == 404
class TestGetPostBySlug:
"""Test suite for GET /api/v1/posts/slug/{slug} endpoint."""
async def test_get_post_by_slug_success(
self,
sample_post_dto: PostResponseDTO,
) -> None:
"""Test getting a post by slug."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_slug",
return_value=sample_post_dto,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/slug/test-post")
assert response.status_code == 200
data = response.json()
assert data["slug"] == "test-post"
async def test_get_post_by_slug_not_found(self) -> None:
"""Test getting a non-existing post by slug returns 404."""
with patch(
"app.application.use_cases.get_post.GetPostUseCase.by_slug",
side_effect=NotFoundException("Post not found"),
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/posts/slug/non-existing-slug")
assert response.status_code == 404
class TestCreatePostAuth:
"""Test suite for POST /api/v1/posts authentication."""
async def test_create_post_unauthorized(self) -> None:
"""Test post creation without authentication returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/posts",
json={
"title": "Test Post",
"content": "This is test content for the blog post",
},
)
assert response.status_code == 401
class TestUpdatePostAuth:
"""Test suite for PATCH /api/v1/posts/{post_id} authentication."""
async def test_update_post_unauthorized(self) -> None:
"""Test updating post without authentication returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
f"/api/v1/posts/{uuid4()}",
json={"title": "Updated Title"},
)
assert response.status_code == 401
class TestDeletePostAuth:
"""Test suite for DELETE /api/v1/posts/{post_id} authentication."""
async def test_delete_post_unauthorized(self) -> None:
"""Test deleting post without authentication returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.delete(f"/api/v1/posts/{uuid4()}")
assert response.status_code == 401
class TestPublishPostAuth:
"""Test suite for POST /api/v1/posts/{post_id}/publish authentication."""
async def test_publish_post_unauthorized(self) -> None:
"""Test publishing post without authentication returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/api/v1/posts/{uuid4()}/publish")
assert response.status_code == 401
class TestUnpublishPostAuth:
"""Test suite for POST /api/v1/posts/{post_id}/unpublish authentication."""
async def test_unpublish_post_unauthorized(self) -> None:
"""Test unpublishing post without authentication returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/api/v1/posts/{uuid4()}/unpublish")
assert response.status_code == 401
class TestHealthEndpoint:
"""Test suite for health check endpoint."""
async def test_health_check(self) -> None:
"""Test health check endpoint returns ok status."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "app" in data
assert "env" in data
class TestRootRedirect:
"""Test suite for root redirect."""
async def test_root_redirect(self) -> None:
"""Test root URL redirects to web UI."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/")
assert response.status_code == 200
assert "web/" in response.text