refactor: migrate to DDD architecture with Dishka DI

Complete architectural refactoring from simple MVC to Clean Architecture/DDD pattern:

Domain Layer:

- Add entities (Post, BaseEntity) with business logic

- Add value objects (Title, Content, Slug) with validation

- Add repository interfaces (PostRepository)

- Add domain exceptions

Application Layer:

- Add use cases (CreatePost, GetPost, UpdatePost, DeletePost, ListPosts, PublishPost)

- Add DTOs for data transfer

- Add TransactionManager interface

Infrastructure Layer:

- Add SQLAlchemy models and async database connection

- Add SQLAlchemyPostRepository implementation

- Add Dishka DI container with providers

- Add error handlers and middleware

Presentation Layer:

- Add FastAPI routes with Dishka integration

- Add Pydantic schemas

- Add dependency injection using FromDishka[T]

Other Changes:

- Remove old flat structure (api/, common/, core/, modules/)

- Add hatchling build system for package scripts

- Add blog CLI command

- Update AGENTS.md with new architecture docs

- All 48 tests passing, mypy clean, ruff clean
This commit is contained in:
2026-05-01 20:20:41 +03:00
parent b8334efa5a
commit 87b094220d
75 changed files with 2783 additions and 459 deletions

View File

@@ -1,17 +1,16 @@
# API test fixtures
# Provides: httpx.AsyncClient, authentication helpers, test API data
"""API test fixtures."""
from typing import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app_factory
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Create async HTTP client for API testing."""
from app.main import app_factory
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -21,4 +20,4 @@ async def client() -> AsyncGenerator[AsyncClient, None]:
@pytest.fixture
def auth_headers() -> dict[str, str]:
"""Return mock authentication headers."""
return {"Authorization": "Bearer test_token"}
return {"Authorization": "Bearer test_token", "X-User-Id": "user-123"}

View File

@@ -1,20 +1,58 @@
# Integration test fixtures
# Provides: test database, external service connections
"""Integration test fixtures."""
from typing import Generator
from typing import AsyncGenerator
import pytest
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.infrastructure.database.models import Base
# Use in-memory SQLite for tests
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture
def test_db_connection() -> Generator[str, None, None]:
"""Create test database connection."""
# TODO: Implement when DB is added to project
yield "test_db"
@pytest.fixture(scope="session")
def engine() -> AsyncEngine:
"""Create test engine."""
return create_async_engine(
TEST_DATABASE_URL,
echo=False,
future=True,
)
@pytest.fixture
def cleanup_db() -> Generator[None, None, None]:
"""Cleanup database after test."""
@pytest.fixture(scope="session")
def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Create test session factory."""
return async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
@pytest.fixture(autouse=True)
async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]:
"""Setup database tables for each test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# TODO: Implement cleanup logic
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def db_session(
session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
"""Create database session for testing."""
async with session_factory() as session:
yield session
await session.rollback()

View File

@@ -1,41 +0,0 @@
from contextlib import asynccontextmanager
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI
# Предполагаем, что тестируемый модуль называется `myapp`
# Импортируем из него нужные объекты
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan() -> None:
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
app = FastAPI()
# Проверяем, что lifespan - это asynccontextmanager
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
# Проверяем, что контекстный менеджер работает (ничего не ломается)
async with lifespan(app):
pass # Просто убеждаемся, что yield отрабатывает
def test_app_factory() -> None:
"""Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan."""
app = app_factory()
assert isinstance(app, FastAPI)
# Проверяем, что lifespan приложения установлен на функцию lifespan
assert app.router.lifespan_context == lifespan
@patch("app.main.uvicorn.run")
def test_main(mock_uvicorn_run: Mock) -> None:
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,
factory=True,
host="0.0.0.0",
port=8000, # Предполагаемый порт (в коде обрезано, но обычно 8000)
)

View File

View File

@@ -0,0 +1,273 @@
"""Tests for application use cases."""
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from app.application.dtos.post import CreatePostDTO, UpdatePostDTO
from app.application.use_cases import (
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListPostsUseCase,
PublishPostUseCase,
UpdatePostUseCase,
)
from app.domain.entities import Post
from app.domain.exceptions import (
AlreadyExistsException,
ForbiddenException,
NotFoundException,
)
@pytest.fixture
def test_post() -> Post:
"""Create a test post."""
return Post.create(
title_str="Test Post",
content_str="This is test content with enough characters",
author_id="user-123",
tags=["test"],
)
class TestCreatePostUseCase:
@pytest.mark.asyncio
async def test_create_post_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test successful post creation."""
# Setup
mock_post_repository.slug_exists = AsyncMock(return_value=False)
mock_post_repository.add = AsyncMock()
use_case = CreatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = CreatePostDTO(
title="New Post",
content="Content with enough characters",
author_id="user-123",
)
# Execute
result = await use_case.execute(dto)
# Assert
assert result.title == "New Post"
assert result.author_id == "user-123"
mock_post_repository.add.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_create_post_slug_exists(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test post creation with existing slug."""
# Setup
mock_post_repository.slug_exists = AsyncMock(return_value=True)
use_case = CreatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = CreatePostDTO(
title="Existing Post",
content="Content with enough characters",
author_id="user-123",
)
# Execute & Assert
with pytest.raises(AlreadyExistsException):
await use_case.execute(dto)
mock_post_repository.add.assert_not_called()
mock_transaction_manager.commit.assert_not_called()
class TestGetPostUseCase:
@pytest.mark.asyncio
async def test_get_post_by_id_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test successful get post by ID."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
# Execute
result = await use_case.by_id(test_post.id)
# Assert
assert result.id == test_post.id
assert result.title == test_post.title.value
mock_post_repository.get_by_id.assert_called_once_with(test_post.id)
@pytest.mark.asyncio
async def test_get_post_by_id_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test get post by ID when not found."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
post_id = uuid4()
# Execute & Assert
with pytest.raises(NotFoundException):
await use_case.by_id(post_id)
class TestUpdatePostUseCase:
@pytest.mark.asyncio
async def test_update_post_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test successful post update."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.update = AsyncMock()
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = UpdatePostDTO(title="Updated Title")
# Execute
result = await use_case.execute(test_post.id, dto, "user-123")
# Assert
assert result.title == "Updated Title"
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_update_post_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test update post when not found."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = UpdatePostDTO(title="Updated Title")
# Execute & Assert
with pytest.raises(NotFoundException):
await use_case.execute(uuid4(), dto, "user-123")
@pytest.mark.asyncio
async def test_update_post_forbidden(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test update post by different user."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = UpdatePostDTO(title="Updated Title")
# Execute & Assert
with pytest.raises(ForbiddenException):
await use_case.execute(test_post.id, dto, "other-user")
class TestDeletePostUseCase:
@pytest.mark.asyncio
async def test_delete_post_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test successful post deletion."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.delete = AsyncMock()
use_case = DeletePostUseCase(mock_post_repository, mock_transaction_manager)
# Execute
await use_case.execute(test_post.id, "user-123")
# Assert
mock_post_repository.delete.assert_called_once_with(test_post.id)
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_post_forbidden(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test delete post by different user."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
use_case = DeletePostUseCase(mock_post_repository, mock_transaction_manager)
# Execute & Assert
with pytest.raises(ForbiddenException):
await use_case.execute(test_post.id, "other-user")
class TestPublishPostUseCase:
@pytest.mark.asyncio
async def test_publish_post_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test successful post publish."""
# Setup
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.update = AsyncMock()
use_case = PublishPostUseCase(mock_post_repository, mock_transaction_manager)
# Execute
result = await use_case.publish(test_post.id, "user-123")
# Assert
assert result.published is True
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
class TestListPostsUseCase:
@pytest.mark.asyncio
async def test_list_all_posts(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test listing all posts."""
# Setup
mock_post_repository.get_all = AsyncMock(return_value=[test_post])
use_case = ListPostsUseCase(mock_post_repository, mock_transaction_manager)
# Execute
results = await use_case.all_posts()
# Assert
assert len(results) == 1
assert results[0].id == test_post.id
mock_post_repository.get_all.assert_called_once()

View File

@@ -1,18 +1,29 @@
# Unit test fixtures
# Provides: mocks, stubs, isolated test data
"""Unit test fixtures."""
from unittest.mock import AsyncMock, Mock
import pytest
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
@pytest.fixture
def mock_service() -> Mock:
"""Create a mock service for unit testing."""
return Mock()
def mock_post_repository() -> Mock:
"""Create a mock post repository."""
return Mock(spec=PostRepository)
@pytest.fixture
def mock_transaction_manager() -> Mock:
"""Create a mock transaction manager."""
tx_manager = Mock(spec=TransactionManager)
tx_manager.commit = AsyncMock()
tx_manager.rollback = AsyncMock()
return tx_manager
@pytest.fixture
def mock_async_service() -> AsyncMock:
"""Create an async mock service for unit testing."""
"""Create an async mock service."""
return AsyncMock()

View File

View File

@@ -0,0 +1,128 @@
"""Tests for domain entities."""
from uuid import UUID
from app.domain.entities import Post
from app.domain.value_objects import Content, Title
class TestPost:
def test_post_creation(self) -> None:
"""Test creating a post."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
tags=["test", "python"],
)
assert isinstance(post.id, UUID)
assert post.title.value == "Test Title"
assert post.content.value == "This is test content that is long enough"
assert post.slug.value == "test-title"
assert post.author_id == "user-123"
assert post.published is False
assert post.tags == ["test", "python"]
def test_post_publish(self) -> None:
"""Test publishing a post."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
)
assert post.published is False
post.publish()
assert post.published is True
def test_post_unpublish(self) -> None:
"""Test unpublishing a post."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
)
post.publish()
assert post.published is True
post.unpublish()
assert post.published is False
def test_post_update_title(self) -> None:
"""Test updating post title."""
post = Post.create(
title_str="Original Title",
content_str="This is test content that is long enough",
author_id="user-123",
)
old_updated_at = post.updated_at
post.update_title(Title("New Title"))
assert post.title.value == "New Title"
assert post.slug.value == "new-title"
assert post.updated_at > old_updated_at
def test_post_update_content(self) -> None:
"""Test updating post content."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
)
old_updated_at = post.updated_at
post.update_content(Content("Updated content that is also long enough"))
assert post.content.value == "Updated content that is also long enough"
assert post.updated_at > old_updated_at
def test_post_add_tag(self) -> None:
"""Test adding a tag."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
)
post.add_tag("python")
assert "python" in post.tags
# Adding same tag twice should not duplicate
post.add_tag("python")
assert post.tags.count("python") == 1
def test_post_remove_tag(self) -> None:
"""Test removing a tag."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
tags=["python", "fastapi"],
)
post.remove_tag("python")
assert "python" not in post.tags
assert "fastapi" in post.tags
def test_post_to_dict(self) -> None:
"""Test converting post to dict."""
post = Post.create(
title_str="Test Title",
content_str="This is test content that is long enough",
author_id="user-123",
tags=["test"],
)
data = post.to_dict()
assert data["title"] == "Test Title"
assert data["content"] == "This is test content that is long enough"
assert data["slug"] == "test-title"
assert data["author_id"] == "user-123"
assert data["published"] is False
assert data["tags"] == ["test"]
assert "id" in data
assert "created_at" in data
assert "updated_at" in data

View File

@@ -0,0 +1,48 @@
"""Tests for domain exceptions."""
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
ValidationException,
)
class TestDomainExceptions:
def test_base_exception(self) -> None:
"""Test base domain exception."""
exc = DomainException("Something went wrong")
assert exc.message == "Something went wrong"
assert str(exc) == "Something went wrong"
def test_validation_exception(self) -> None:
"""Test validation exception."""
exc = ValidationException("Invalid input")
assert isinstance(exc, DomainException)
assert exc.message == "Invalid input"
def test_not_found_exception(self) -> None:
"""Test not found exception."""
exc = NotFoundException("Resource not found")
assert isinstance(exc, DomainException)
assert exc.message == "Resource not found"
def test_already_exists_exception(self) -> None:
"""Test already exists exception."""
exc = AlreadyExistsException("Already exists")
assert isinstance(exc, DomainException)
assert exc.message == "Already exists"
def test_unauthorized_exception(self) -> None:
"""Test unauthorized exception."""
exc = UnauthorizedException("Unauthorized")
assert isinstance(exc, DomainException)
assert exc.message == "Unauthorized"
def test_forbidden_exception(self) -> None:
"""Test forbidden exception."""
exc = ForbiddenException("Forbidden")
assert isinstance(exc, DomainException)
assert exc.message == "Forbidden"

View File

@@ -0,0 +1,93 @@
"""Tests for domain value objects."""
import pytest
from app.domain.value_objects import Content, Slug, Title
class TestTitle:
def test_valid_title(self) -> None:
"""Test creating a valid title."""
title = Title("Valid Title")
assert title.value == "Valid Title"
def test_title_too_short(self) -> None:
"""Test title that is too short."""
with pytest.raises(ValueError, match="at least"):
Title("ab")
def test_title_too_long(self) -> None:
"""Test title that is too long."""
with pytest.raises(ValueError, match="at most"):
Title("a" * 201)
def test_title_empty(self) -> None:
"""Test empty title."""
with pytest.raises(ValueError, match="empty"):
Title(" ")
def test_title_not_string(self) -> None:
"""Test non-string title."""
with pytest.raises(ValueError, match="string"):
Title(123) # type: ignore[arg-type]
class TestContent:
def test_valid_content(self) -> None:
"""Test creating valid content."""
content = Content("This is valid content with enough characters")
assert content.value == "This is valid content with enough characters"
def test_content_too_short(self) -> None:
"""Test content that is too short."""
with pytest.raises(ValueError, match="at least"):
Content("short")
def test_content_too_long(self) -> None:
"""Test content that is too long."""
with pytest.raises(ValueError, match="at most"):
Content("a" * 50001)
def test_content_empty(self) -> None:
"""Test empty content."""
with pytest.raises(ValueError, match="empty"):
Content(" ")
class TestSlug:
def test_valid_slug(self) -> None:
"""Test creating a valid slug."""
slug = Slug("valid-slug")
assert slug.value == "valid-slug"
def test_slug_from_title(self) -> None:
"""Test generating slug from title."""
slug = Slug.from_title("Hello World Post")
assert slug.value == "hello-world-post"
def test_slug_from_title_with_special_chars(self) -> None:
"""Test generating slug from title with special characters."""
slug = Slug.from_title("Hello, World! Post @#$%")
assert slug.value == "hello-world-post"
def test_slug_from_title_only_special_chars(self) -> None:
"""Test generating slug from title with only special characters."""
slug = Slug.from_title("!@#$%")
assert slug.value == "post"
def test_slug_invalid_chars(self) -> None:
"""Test slug with invalid characters."""
with pytest.raises(ValueError, match="lowercase"):
Slug("Invalid_Slug")
def test_slug_uppercase(self) -> None:
"""Test slug with uppercase letters."""
with pytest.raises(ValueError, match="lowercase"):
Slug("Uppercase-Slug")
def test_slug_equality(self) -> None:
"""Test slug value equality."""
slug1 = Slug("test-slug")
slug2 = Slug("test-slug")
assert slug1 == slug2
assert hash(slug1) == hash(slug2)

View File

View File

@@ -0,0 +1,37 @@
"""Tests for infrastructure config."""
from app.infrastructure.config import Settings
class TestSettings:
def test_default_values(self) -> None:
"""Test default settings values by creating settings without env file."""
# Create settings with no env file to test defaults
s = Settings(_env_file=None)
assert s.app_name == "Blog API"
assert s.debug is False
assert s.host == "0.0.0.0"
assert s.port == 8000
assert s.database_url == "sqlite:///./blog.db"
assert s.database_echo is False
def test_custom_values(self) -> None:
"""Test custom settings values."""
s = Settings(
app_name="Test API",
debug=True,
host="localhost",
port=9000,
database_url="postgresql://test",
secret_key="test-secret",
)
assert s.app_name == "Test API"
assert s.debug is True
assert s.host == "localhost"
assert s.port == 9000
assert s.database_url == "postgresql://test"
assert s.secret_key == "test-secret"
def test_model_config(self) -> None:
"""Test settings model config."""
assert "env_file" in Settings.model_config

View File

@@ -1,52 +0,0 @@
import os
from unittest.mock import patch
from app.core.config import Settings
class TestSettings:
def test_default_values(self) -> None:
settings = Settings()
assert settings.app_name == "Blog API"
assert settings.debug is False
assert settings.host == "0.0.0.0"
assert settings.port == 8000
assert settings.database_url is None
def test_custom_values(self) -> None:
settings = Settings(
app_name="Test API",
debug=True,
host="localhost",
port=9000,
database_url="postgresql://test",
)
assert settings.app_name == "Test API"
assert settings.debug is True
assert settings.host == "localhost"
assert settings.port == 9000
assert settings.database_url == "postgresql://test"
def test_settings_from_env(self) -> None:
with patch.dict(
os.environ,
{
"APP_NAME": "Env API",
"DEBUG": "true",
"HOST": "127.0.0.1",
"PORT": "8080",
"DATABASE_URL": "sqlite:///test.db",
},
):
settings = Settings()
assert settings.app_name == "Env API"
assert settings.debug is True
assert settings.host == "127.0.0.1"
assert settings.port == 8080
assert settings.database_url == "sqlite:///test.db"
def test_global_settings_instance(self) -> None:
from app.core.config import settings
assert isinstance(settings, Settings)
assert settings.app_name == "Blog API"

View File

@@ -1,110 +0,0 @@
from datetime import datetime, timezone
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI, Request
from starlette.exceptions import HTTPException
from app.common.error_handler import (
ErrorResponse,
app_exception_handler,
http_exception_handler,
register_exception_handlers,
)
from app.core.exceptions import AppException
class TestErrorResponse:
def test_error_response_creation(self) -> None:
response = ErrorResponse(
status_code=400,
message="Bad request",
timestamp=datetime.now(timezone.utc).isoformat(),
)
assert response.status_code == 400
assert response.message == "Bad request"
assert response.details is None
def test_error_response_with_details(self) -> None:
response = ErrorResponse(
status_code=500,
message="Internal error",
details={"field": "value"},
timestamp=datetime.now(timezone.utc).isoformat(),
)
assert response.status_code == 500
assert response.message == "Internal error"
assert response.details == {"field": "value"}
class TestAppExceptionHandler:
@pytest.mark.asyncio
async def test_app_exception_handler(self) -> None:
request = Mock(spec=Request)
exc = AppException(message="Test error", status_code=400)
response = await app_exception_handler(request, exc)
assert response.status_code == 400
body = bytes(response.body).decode()
assert "Test error" in body
assert "400" in body
@pytest.mark.asyncio
async def test_app_exception_handler_content(self) -> None:
request = Mock(spec=Request)
exc = AppException(message="Validation error", status_code=422)
with patch("app.common.error_handler.datetime") as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = (
"2024-01-01T00:00:00"
)
response = await app_exception_handler(request, exc)
content = bytes(response.body).decode()
assert "Validation error" in content
assert "422" in content
assert "2024-01-01T00:00:00" in content
class TestHttpExceptionHandler:
@pytest.mark.asyncio
async def test_http_exception_handler(self) -> None:
request = Mock(spec=Request)
exc = HTTPException(status_code=404, detail="Not found")
response = await http_exception_handler(request, exc)
assert response.status_code == 404
body = bytes(response.body).decode()
assert "Not found" in body
assert "404" in body
@pytest.mark.asyncio
async def test_http_exception_handler_content(self) -> None:
request = Mock(spec=Request)
exc = HTTPException(status_code=503, detail="Service unavailable")
with patch("app.common.error_handler.datetime") as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = (
"2024-01-01T12:00:00"
)
response = await http_exception_handler(request, exc)
content = bytes(response.body).decode()
assert "Service unavailable" in content
assert "503" in content
assert "2024-01-01T12:00:00" in content
class TestRegisterExceptionHandlers:
def test_register_exception_handlers(self) -> None:
app = Mock(spec=FastAPI)
register_exception_handlers(app)
assert app.add_exception_handler.call_count == 2
app.add_exception_handler.assert_any_call(AppException, app_exception_handler)
app.add_exception_handler.assert_any_call(HTTPException, http_exception_handler)

View File

@@ -1,87 +0,0 @@
from app.core.exceptions import (
AppException,
ForbiddenError,
NotFoundError,
UnauthorizedError,
ValidationError,
)
class TestAppException:
def test_default_status_code(self) -> None:
exc = AppException(message="Test error")
assert exc.message == "Test error"
assert exc.status_code == 500
def test_custom_status_code(self) -> None:
exc = AppException(message="Custom error", status_code=400)
assert exc.message == "Custom error"
assert exc.status_code == 400
def test_string_representation(self) -> None:
exc = AppException(message="Error message")
assert str(exc) == "Error message"
class TestNotFoundError:
def test_default_message(self) -> None:
exc = NotFoundError()
assert exc.message == "Resource not found"
assert exc.status_code == 404
def test_custom_message(self) -> None:
exc = NotFoundError(message="Item not found")
assert exc.message == "Item not found"
assert exc.status_code == 404
def test_is_subclass_of_app_exception(self) -> None:
exc = NotFoundError()
assert isinstance(exc, AppException)
class TestValidationError:
def test_default_message(self) -> None:
exc = ValidationError()
assert exc.message == "Validation failed"
assert exc.status_code == 400
def test_custom_message(self) -> None:
exc = ValidationError(message="Invalid email format")
assert exc.message == "Invalid email format"
assert exc.status_code == 400
def test_is_subclass_of_app_exception(self) -> None:
exc = ValidationError()
assert isinstance(exc, AppException)
class TestUnauthorizedError:
def test_default_message(self) -> None:
exc = UnauthorizedError()
assert exc.message == "Unauthorized"
assert exc.status_code == 401
def test_custom_message(self) -> None:
exc = UnauthorizedError(message="Invalid credentials")
assert exc.message == "Invalid credentials"
assert exc.status_code == 401
def test_is_subclass_of_app_exception(self) -> None:
exc = UnauthorizedError()
assert isinstance(exc, AppException)
class TestForbiddenError:
def test_default_message(self) -> None:
exc = ForbiddenError()
assert exc.message == "Forbidden"
assert exc.status_code == 403
def test_custom_message(self) -> None:
exc = ForbiddenError(message="Access denied")
assert exc.message == "Access denied"
assert exc.status_code == 403
def test_is_subclass_of_app_exception(self) -> None:
exc = ForbiddenError()
assert isinstance(exc, AppException)

49
tests/unit/test_main.py Normal file
View File

@@ -0,0 +1,49 @@
"""Tests for main application."""
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan() -> None:
"""Test lifespan context manager."""
app = FastAPI()
with (
patch("app.main.init_db") as mock_init,
patch("app.main.close_db") as mock_close,
):
async with lifespan(app):
mock_init.assert_called_once()
mock_close.assert_not_called()
mock_close.assert_called_once()
def test_app_factory() -> None:
"""Test app factory creates FastAPI app."""
app = app_factory()
assert isinstance(app, FastAPI)
def test_app_factory_has_routes() -> None:
"""Test app has registered routes."""
app = app_factory()
routes = [str(route.path) for route in app.routes if hasattr(route, "path")]
assert "/health" in routes
# Check that API routes are included
assert any("api" in path for path in routes)
@patch("app.main.uvicorn.run")
def test_main(mock_uvicorn_run: Mock) -> None:
"""Test main function starts uvicorn."""
main()
mock_uvicorn_run.assert_called_once()
call_kwargs = mock_uvicorn_run.call_args.kwargs
assert call_kwargs.get("factory") is True
assert call_kwargs.get("host") == "0.0.0.0"
assert call_kwargs.get("port") == 8000

View File

@@ -1,33 +0,0 @@
from contextlib import asynccontextmanager
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan() -> None:
app = FastAPI()
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
async with lifespan(app):
pass
def test_app_factory() -> None:
app = app_factory()
assert isinstance(app, FastAPI)
assert app.router.lifespan_context == lifespan
@patch("app.main.uvicorn.run")
def test_main(mock_uvicorn_run: Mock) -> None:
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,
factory=True,
host="0.0.0.0",
port=8000,
)